diff options
| author | Adam Soltys <[email protected]> | 2026-08-01 19:38:30 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-08-01 19:38:30 -0700 |
| commit | 348a57004f94dd0f58c4e70769e65ca7ea3f7619 (patch) | |
| tree | 5f8cd2930abd9c1e05bb7c18e0bec239780281ef /src | |
| parent | 9196f4804851a87c8356418b1c4cade6e87c54ba (diff) | |
| download | coinosv3-348a57004f94dd0f58c4e70769e65ca7ea3f7619.tar.gz coinosv3-348a57004f94dd0f58c4e70769e65ca7ea3f7619.zip | |
Simplify: one Lightning path, one balance, fewer features
Dropped: silent payments (feature, wallet chains, scan worker, indexer
config), Boltz submarine/reverse swaps (feature + swap.js + tools), the
third-party ark swap bridge (config, client flows, settings card — the
ASP's own Lightning send is the one path now), and the Consolidate
coins settings action.
The NWC pouch is gone with them: background answering now spends the
WHOLE ark balance. The IndexedDB record mirrors the real manager state
plus the chain-3 keys for its live coins and a change-index window; the
worker pays through the ASP exactly like the open app, and the app
folds the worker's spends, change, and key allocations back in on
connect (additive merge + reconcile against the server). A leftover
chain-6 pouch from the short-lived pouch era is swept home
automatically using the keys stored in its own record. The stated
tradeoff: same-origin script access now reaches the ark balance, not a
carve-out — the ark balance is walking-around money, the on-chain
wallet's keys are never mirrored, and per-connection NWC budgets remain
the enforced ceiling.
Bundle: 726KB -> 602KB.
Co-Authored-By: Claude Fable 5 <[email protected]>
Diffstat (limited to 'src')
| -rw-r--r-- | src/api.js | 102 | ||||
| -rw-r--r-- | src/app.js | 29 | ||||
| -rw-r--r-- | src/ark/manager.js | 26 | ||||
| -rw-r--r-- | src/features/ark.js | 362 | ||||
| -rw-r--r-- | src/features/index.js | 7 | ||||
| -rw-r--r-- | src/features/nwc.js | 110 | ||||
| -rw-r--r-- | src/features/sp-wallet.js | 544 | ||||
| -rw-r--r-- | src/features/sp.js | 66 | ||||
| -rw-r--r-- | src/features/swaps.js | 239 | ||||
| -rw-r--r-- | src/i18n.js | 32 | ||||
| -rw-r--r-- | src/nwc-bg.js | 117 | ||||
| -rw-r--r-- | src/nwc-pouch.js | 148 | ||||
| -rw-r--r-- | src/nwc-respond.js | 167 | ||||
| -rw-r--r-- | src/sp-worker.js | 69 | ||||
| -rw-r--r-- | src/sw-nwc.js | 9 | ||||
| -rw-r--r-- | src/swap.js | 459 | ||||
| -rw-r--r-- | src/wallet.js | 15 |
17 files changed, 307 insertions, 2194 deletions
@@ -273,108 +273,6 @@ export function getArkConfig(net = getNetwork()) { return cfg.ark && cfg.esplora ? cfg : null; } -// Ark->Lightning swap bridge — an optional third-party provider that pays -// bolt11 invoices out of your Ark balance for less than the ASP charges. The -// swap is trustless (funds are locked in an HTLC only the bridge can claim, -// and only by revealing the invoice preimage), but it requires an ASP that -// supports third-party HTLC vtxos. Off means bolt11 payments use the ASP's -// own — pricier — lightning-send path. -const BRIDGE_PRESETS_BY_NET = { - mainnet: [ - { id: 'off', label: 'Off (use the ASP)', url: '' }, - { id: 'coinos', label: 'coinos (arkswap.coinos.io)', url: 'https://arkswap.coinos.io' }, - { id: 'custom', label: 'Custom…', url: '' }, - ], - testnet: [{ id: 'off', label: 'Off (use the ASP)', url: '' }, { id: 'custom', label: 'Custom…', url: '' }], - signet: [{ id: 'off', label: 'Off (use the ASP)', url: '' }, { id: 'custom', label: 'Custom…', url: '' }], - mutinynet: [ - { id: 'off', label: 'Off (use the ASP)', url: '' }, - { id: 'coinos', label: 'coinos (arkswap-staging.coinos.io)', url: 'https://arkswap-staging.coinos.io' }, - { id: 'custom', label: 'Custom…', url: '' }, - ], - regtest: [ - { id: 'off', label: 'Off (use the ASP)', url: '' }, - { id: 'local', label: 'Local (regtest)', url: 'http://localhost:8790' }, - { id: 'custom', label: 'Custom…', url: '' }, - ], -}; -// Mainnet defaults to the coinos bridge: small bolt11 sends (zaps) are -// unroutable through the ASP path, whose CLN fee budget is a fraction of the -// ASP's own 3-sat minimum fee. The bridge runs open (no token) for this. -const BRIDGE_DEFAULT = { mainnet: 'coinos', testnet: 'off', signet: 'off', mutinynet: 'off', regtest: 'off' }; -const BRIDGE_KEY = 'btc-wallet-ark-bridge'; // selected preset id, per network -const BRIDGE_CUSTOM_KEY = 'btc-wallet-ark-bridge-custom'; // custom url, per network -const BRIDGE_TOKEN_KEY = 'btc-wallet-ark-bridge-token'; // access token, per network - -export function bridgePresets(net = getNetwork()) { - return BRIDGE_PRESETS_BY_NET[net] || BRIDGE_PRESETS_BY_NET.mainnet; -} -export function getBridgeProviderId(net = getNetwork()) { - try { - const id = localStorage.getItem(nk(BRIDGE_KEY, net)); - if (id && bridgePresets(net).some((p) => p.id === id)) return id; - } catch {} - return BRIDGE_DEFAULT[net] || 'off'; -} -export function setBridgeProviderId(id, net = getNetwork()) { - try { localStorage.setItem(nk(BRIDGE_KEY, net), id); } catch {} -} -export function getBridgeCustom(net = getNetwork()) { - try { return localStorage.getItem(nk(BRIDGE_CUSTOM_KEY, net)) || ''; } catch { return ''; } -} -export function setBridgeCustom(url, net = getNetwork()) { - try { localStorage.setItem(nk(BRIDGE_CUSTOM_KEY, net), (url || '').trim()); } catch {} -} -export function getBridgeToken(net = getNetwork()) { - try { return localStorage.getItem(nk(BRIDGE_TOKEN_KEY, net)) || ''; } catch { return ''; } -} -export function setBridgeToken(tok, net = getNetwork()) { - try { localStorage.setItem(nk(BRIDGE_TOKEN_KEY, net), (tok || '').trim()); } catch {} -} -// { url, token } when a bridge is configured, else null. -export function getBridgeConfig(net = getNetwork()) { - const id = getBridgeProviderId(net); - if (id === 'off') return null; - const p = bridgePresets(net).find((x) => x.id === id); - const url = id === 'custom' ? getBridgeCustom(net) : p.url; - return url ? { url: url.replace(/\/$/, ''), token: getBridgeToken(net) } : null; -} - -// Silent-payment (BIP-352) tweak indexer — a separate, optional endpoint used -// only for SP receiving. It serves public tweak/UTXO data (the scan key never -// leaves the device), but a public one can observe which blocks hold your -// outputs, so self-hosting is the privacy-max option. Per network; an empty URL -// means SP receiving is unavailable on that network. -const SP_INDEXER_PRESETS_BY_NET = { - mainnet: [{ id: 'coinos', label: 'coinos', url: 'https://sp.coinos.io' }, { id: 'custom', label: 'Custom', url: '' }], - testnet: [{ id: 'custom', label: 'Custom', url: '' }], - signet: [{ id: 'custom', label: 'Custom', url: '' }], - mutinynet: [{ id: 'custom', label: 'Custom', url: '' }], - regtest: [{ id: 'local', label: 'Local (proxied)', url: '/sp' }, { id: 'custom', label: 'Custom', url: '' }], -}; -const SP_INDEXER_DEFAULT = { mainnet: 'coinos', testnet: 'custom', signet: 'custom', mutinynet: 'custom', regtest: 'local' }; -const SP_INDEXER_KEY = 'btc-wallet-sp-indexer'; -export function spIndexerPresets(net = getNetwork()) { return SP_INDEXER_PRESETS_BY_NET[net] || SP_INDEXER_PRESETS_BY_NET.mainnet; } -export function getSpIndexerConfig(net = getNetwork()) { - try { - const c = JSON.parse(localStorage.getItem(nk(SP_INDEXER_KEY, net)) || 'null'); - if (c && c.server) return { server: c.server, url: c.url || '' }; - } catch {} - return { server: SP_INDEXER_DEFAULT[net] || 'custom', url: '' }; -} -export function setSpIndexerConfig({ server, url }, net = getNetwork()) { - try { localStorage.setItem(nk(SP_INDEXER_KEY, net), JSON.stringify({ server, url: url || '' })); } catch {} -} -// The resolved indexer base URL for a network, or '' if none is configured. -export function spIndexerUrl(net = getNetwork()) { - const cfg = getSpIndexerConfig(net); - if (cfg.server === 'custom') return cfg.url.trim().replace(/\/+$/, ''); - const p = spIndexerPresets(net).find((x) => x.id === cfg.server); - return p && p.url ? p.url.replace(/\/+$/, '') : ''; -} -// SP receiving is available only when an indexer is configured for this network. -export function spReceiveEnabled(net = getNetwork()) { return !!spIndexerUrl(net); } - // Resolve the configured explorer to the host list the Api tries in order. function resolveHosts(net) { const src = getSource(net); @@ -82,7 +82,6 @@ const ui = { giftDmStatus: null, // 'sending' | 'sent' | 'failed' — nostr DM of the claim code claimLocked: null, // a locked gift being opened: { v, amount, to, ct } claimCodeInput: '', // the claim code the recipient pastes from their nostr DM - consolidateError: null, // error from a coin-consolidation attempt viewGift: null, // re-viewing a previously created gift's link/QR { code, locked, amount, claimCode } claimChoose: null, // opening a gift with existing wallets present: { code } — pick a target giftCode: null, // last-created gift PSBT code @@ -1476,38 +1475,12 @@ function settingsTab() { ), networkCard(), ...featureAll('settingsCards'), - consolidateCard(), explorerCard(), null ); } -// Merge many small coins into one. Useful after lots of small receives/gifts — -// keeps future sends/gifts to a single input (cheaper, smaller gift QR). -function consolidateCard() { - if (wallet.watchOnly) return null; - const n = wallet.spendableCoinCount ? wallet.spendableCoinCount() : 0; - if (n < 2) return null; - return h('div', { class: 'card col' }, - h('h3', {}, t('consolidateTitle')), - h('p', { class: 'small muted', style: 'margin:0' }, t('consolidateDesc', { n })), - ui.consolidateError && h('div', { class: 'notice err' }, ui.consolidateError), - h('button', { class: 'btn-block', disabled: ui.busy || wallet.offline, onClick: doConsolidate }, ui.busy ? h('span', { class: 'spinner' }) : t('consolidateAction', { n }))); -} -async function doConsolidate() { - if (wallet.offline) { ui.consolidateError = t('scanOffline'); render(); return; } - ui.busy = true; ui.consolidateError = ''; render(); - try { - const rate = (wallet.feeRates && wallet.feeRates.halfHourFee) || 5; - await wallet.consolidate(rate); - toast(t('consolidateDone')); - wallet.scan().catch(() => {}); - } catch (e) { - ui.consolidateError = e.message || t('consolidateFailed'); - } - ui.busy = false; - render(); -} + diff --git a/src/ark/manager.js b/src/ark/manager.js index 2edcd8a..5a797e7 100644 --- a/src/ark/manager.js +++ b/src/ark/manager.js @@ -500,32 +500,6 @@ export class ArkManager { return action.id; } - // A send whose recipient is one of the USER's own foreign-branch keys (the - // NWC pouch): no address, no mailbox delivery — the caller receives the - // signed recipient vtxo bytes directly and stores them wherever the branch - // lives. Same cosign machinery as send(), so all its failure handling holds. - async sendToPubkey(destPubkeyHex, amountSat) { - const input = this._selectInput(amountSat); - const changeSat = input.amountSat - amountSat; - const action = { - id: `send-${Date.now()}`, type: 'send', step: 'created', - inputId: input.id, amountSat, destAddress: null, - destPubkey: destPubkeyHex, destBlindedId: null, - changeIndex: changeSat > 0 ? this.state.nextKeyIndex++ : null, changeSat, - }; - input.state = 'pending'; - this.state.actions.push(action); - this._save(); - await this._driveSend(action); - if (action.step !== 'done') throw new Error(action.error || 'send did not complete'); - return { - vtxos: (action.destBytesList || []).map((b) => { - const v = decodeVtxo(hex.decode(b)); - return { id: v.id, bytes: b, amountSat: v.amountSat, expiryHeight: v.expiryHeight }; - }), - }; - } - _sendOutputs(action) { const outputs = [{ amountSat: action.amountSat, userPubkey: hex.decode(action.destPubkey) }]; if (action.changeSat > 0) { diff --git a/src/features/ark.js b/src/features/ark.js index c163583..f84857d 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -7,7 +7,7 @@ import { HDKey } from '@scure/bip32'; import { hex, base32nopad, bech32 } from '@scure/base'; import { sha256 } from '@noble/hashes/sha256'; import { ArkManager } from '../ark/manager.js'; -import { pouchKey, loadPouch, savePouch, POUCH_CHANGE_BASE } from '../nwc-pouch.js'; +import { loadBg, saveBg, buildBg } from '../nwc-bg.js'; import { boardFee } from '../ark/board.js'; import { maybeBolt11, lnSendFee } from '../ark/lightning.js'; import { decodeVtxo, getVtxoStatus, VTXO_STATE_SPENT, concatBytes } from '../ark/proto.js'; @@ -16,8 +16,6 @@ import { utxoId } from '../wallet.js'; import { getNetwork, setNetwork, arkPresets, getArkProviderId, setArkProviderId, getArkCustom, setArkCustom, getArkConfig, - bridgePresets, getBridgeProviderId, setBridgeProviderId, getBridgeCustom, - setBridgeCustom, getBridgeToken, setBridgeToken, getBridgeConfig, } from '../api.js'; import { t } from '../i18n.js'; import { qrSvg } from '../qr.js'; @@ -333,7 +331,7 @@ export function arkFeature(ctx) { // against the server, so a stale spendable is caught here rather than // only at send time. mgr.reconcile().catch(() => {}); - resumeBridgeSwaps(mgr).catch(() => {}); // refund any HTLC a bridge left hanging + mergeBgWorkerState(mgr).catch(() => {}); // absorb what the SW did while we were closed // Receives arrive in real time over the mailbox stream; the poll is the // fallback and what drives in-flight boards/refreshes forward. mgr.startMailboxStream(); @@ -452,128 +450,77 @@ export function arkFeature(ctx) { }, 2500)); } - // ---- third-party swap bridge (cheaper bolt11 sends) --------------------- - // When a bridge is configured we lock the amount in an HTLC only it can - // claim — and only by revealing the invoice preimage. Trustless: if it - // never pays we get a cosigned refund, or exit through the refund leaf. - // Requires an ASP supporting HTLC vtxos, so any failure falls back to the - // ASP's own (pricier) lightning-send. - async function bridgeQuote(invoice) { - const cfg = getBridgeConfig(); - if (!cfg) return null; - const r = await fetch(`${cfg.url}/quote?invoice=${encodeURIComponent(invoice)}`, - { headers: cfg.token ? { authorization: `Bearer ${cfg.token}` } : {} }); - const q = await r.json(); - if (!r.ok || q.error) throw new Error(q.error || `bridge quote failed (${r.status})`); - return { ...q, bridgeUrl: cfg.url, token: cfg.token }; - } - - // Lock the HTLC, hand it to the bridge, and record the swap so a crash or - // a failed payment can still be refunded later. - async function bridgePay(quote, invoice) { - const mgr = await connectArk(); - const lock = await mgr.htlcLock({ - amountSat: quote.totalSat, - claimPubkey: quote.claimPubkey, - paymentHash: quote.paymentHash, - htlcExpiry: quote.htlcExpiry, + // ---- background NWC mirror --------------------------------------------- + // The service worker answers wallet-connect requests while the app is + // closed, spending the SAME ark balance the app does. The mirror is the + // full manager state plus the chain-3 keys for its live coins and a window + // of upcoming change indices (the worker cannot derive). nwc.js decides + // WHEN to mirror (background answering on) and supplies the connections. + async function writeBgMirror(connections) { + const cfg = getArkConfig(); + if (!cfg || !ark || !ark.info || !ark.state) return; + const prev = await loadBg(wallet._cacheKey()); + const rec = buildBg({ + ark: { arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: getNetwork(), serverPubkey: ark.info.serverPubkey }, + mgrState: ark.state, + keyFor: (i) => hex.encode(ark._key(i).privkey), + connections, }); - // persist before handing it over: the HTLC exists on the server now - const swaps = (mgr.state.bridgeSwaps = mgr.state.bridgeSwaps || []); - const rec = { - paymentHash: quote.paymentHash, invoice, bridgeUrl: quote.bridgeUrl, - amountSat: quote.amountSat, feeSat: quote.feeSat, - htlcVtxo: lock.htlcVtxos[0], refundIndex: lock.refundIndex, - htlcExpiry: quote.htlcExpiry, step: 'submitted', ts: Date.now(), - }; - swaps.push(rec); - mgr._save(); + if (prev) rec.spends = prev.spends; // the worker's log survives rewrites + await saveBg(wallet._cacheKey(), rec); + } - let res; - try { - const r = await fetch(`${quote.bridgeUrl}/swap`, { - method: 'POST', - headers: { - 'content-type': 'application/json', - ...(quote.token ? { authorization: `Bearer ${quote.token}` } : {}), - }, - body: JSON.stringify({ invoice, htlcVtxo: lock.htlcVtxos[0] }), - }); - res = await r.json(); - if (!r.ok || res.error) throw new Error(res.error || `bridge error (${r.status})`); - } catch (e) { - rec.step = 'failed'; - rec.error = e.message; - mgr._save(); - await tryBridgeRefund(rec).catch(() => {}); - throw e; - } - if (res.step !== 'done') { - rec.step = 'unpaid'; - mgr._save(); - await tryBridgeRefund(rec).catch(() => {}); - throw new Error(res.error || t('arkLnRefunded')); + // On connect, fold whatever the worker did while we were closed back into + // the live state: its spent flips and change vtxos merge in (the additive + // merge never resurrects anything reconcile() disproves), its key + // allocations advance ours, and its in-flight ln actions become resumable. + async function mergeBgWorkerState(mgr) { + const rec = await loadBg(wallet._cacheKey()); + if (!rec) return; + if (rec.v < 3) { + await sweepLegacyPouch(rec).catch((e) => console.warn('ark: pouch sweep failed', e.message)); + return; } - // proof of payment: the preimage must hash to the invoice's payment hash - if (!res.preimage || hex.encode(sha256(hex.decode(res.preimage))) !== quote.paymentHash) { - rec.step = 'suspect'; - mgr._save(); - throw new Error('bridge returned an invalid preimage'); + if (!rec.mgr) return; + const merged = mergeArkStates(mgr.state, rec.mgr); + // the worker's view of OUR vtxos is newer for anything it spent + const workerState = new Map((rec.mgr.vtxos || []).map((v) => [v.id, v.state])); + for (const v of merged.vtxos) { + if (workerState.get(v.id) === 'spent') v.state = 'spent'; } - rec.step = 'done'; - rec.preimage = res.preimage; + merged.nextKeyIndex = Math.max(merged.nextKeyIndex || 1, rec.mgr.nextKeyIndex || 1); + mgr.state = merged; mgr._save(); - mgr._movement({ - type: 'ln-send', amountSat: quote.amountSat, status: 'complete', - detail: `via bridge · fee ${quote.feeSat} sat`, invoice, preimage: res.preimage, - }); - mgr._save(); - return res; + await mgr.reconcile().catch(() => {}); + await mgr.resumePending().catch(() => {}); } - // An ephemeral manager over the POUCH's chain-6 keys — used to empty the - // pouch and to refund worker-initiated swaps (their refund pubkeys are - // pouch keys, which the main manager cannot sign for). State persists back - // into the pouch record so the service worker sees the same coin set. - async function pouchManager(pouch) { + // A leftover chain-6 pouch from before whole-balance background spending: + // sweep its coins home using the keys stored in the record itself, then + // let the next mirror write replace it. + async function sweepLegacyPouch(rec) { + if (!(rec.vtxos || []).some((v) => (v.state || 'spendable') === 'spendable')) return; const cfg = getArkConfig(); - const account6 = { deriveChild: () => ({ deriveChild: (i) => ({ privateKey: hex.decode(pouch.keys[String(i)] || pouch.keys['0']) }) }) }; - const storage = { - load: () => pouch.mgr || { - v: 1, serverPubkey: pouch.ark.serverPubkey || null, mailboxCheckpoint: 0, - nextKeyIndex: pouch.nextKeyIndex || POUCH_CHANGE_BASE, - vtxos: (pouch.vtxos || []).map((v) => ({ ...v, state: v.state || 'spendable' })), - actions: [], movements: [], - }, - save: (s) => { pouch.mgr = s; savePouch(wallet._cacheKey(), pouch).catch(() => {}); }, + const mgr = await connectArk(); + const shim = { deriveChild: () => ({ deriveChild: (i) => ({ privateKey: hex.decode(rec.keys[String(i)] || rec.keys['0']) }) }) }; + let state = { + v: 1, serverPubkey: rec.ark?.serverPubkey || null, mailboxCheckpoint: 0, + nextKeyIndex: rec.nextKeyIndex || 20, + vtxos: (rec.mgr?.vtxos || rec.vtxos || []).map((v) => ({ ...v, state: v.state || 'spendable' })), + actions: [], movements: [], }; - return new ArkManager({ - account: account6, storage, + const pm = await new ArkManager({ + account: shim, + storage: { load: () => state, save: (s2) => { state = s2; } }, arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: getNetwork(), }).init(); - } - - // Ask the bridge to hand an unpaid HTLC back, and take ownership of it. - async function tryBridgeRefund(rec) { - const mgr = await connectArk(); - const r = await fetch(`${rec.bridgeUrl}/refund`, { - method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ paymentHash: rec.paymentHash }), - }); - const out = await r.json(); - if (!r.ok || out.error) throw new Error(out.error || 'refund failed'); - const got = await mgr.acceptHtlcRefund({ - vtxoBytesList: out.refundVtxos, refundIndex: rec.refundIndex, - }); - rec.step = 'refunded'; - mgr._save(); - return got; - } - - // Retry refunds for any bridge swap left hanging (called on connect). - async function resumeBridgeSwaps(mgr) { - const open = (mgr.state.bridgeSwaps || []).filter((r) => ['unpaid', 'failed'].includes(r.step)); - for (const rec of open) await tryBridgeRefund(rec).catch(() => {}); + const dest = mgr.address(); + for (let guard = 0; guard < 30; guard++) { + const v = (pm.state.vtxos || []).find((x) => x.state === 'spendable'); + if (!v) break; + await pm.send(dest, v.amountSat); + } + await saveBg(wallet._cacheKey(), { ...rec, vtxos: [], mgr: state, sweptAt: Date.now() }); } // Pay a bolt11 with no UI at all — used by the NWC wallet service. Same @@ -593,18 +540,6 @@ export function arkFeature(ctx) { // payment picks the stale coin and the ASP rejects it ("state: spent"). await mgr.reconcile().catch(() => {}); - // 1. the bridge, when one is configured and can take it - try { - const q = await bridgeQuote(invoice); - if (q && mgr.vtxos().some((v) => v.state === 'spendable' && v.amountSat >= q.totalSat)) { - const res = await bridgePay(q, invoice); - return { preimage: res.preimage, feeSat: q.feeSat, amountSat: q.amountSat }; - } - } catch (e) { - console.warn('nwc: bridge pay failed, falling back to the ASP:', e.message); - } - - // 2. the ASP's native lightning-send const id = await mgr.payLnInvoice(invoice); for (let i = 0; i < 60; i++) { const a = mgr.lnAction(id); @@ -648,23 +583,6 @@ export function arkFeature(ctx) { const p = ui.arkLnPay; if (!p || p.invoice !== invoice) return; if (!p.amountSat) { p.status = 'ready'; render(); return; } - // A configured bridge is cheaper than the ASP; quote it first and fall - // back silently if it's unreachable or won't take the swap. - try { - const q = await bridgeQuote(invoice); - if (q && ui.arkLnPay === p) { - const covers = mgr.vtxos().some((v) => v.state === 'spendable' && v.amountSat >= q.totalSat); - if (covers) { - p.bridge = q; - p.feeSat = q.feeSat; - p.status = 'ready'; - render(); - return; - } - } - } catch (e) { - console.warn('bridge quote failed, using the ASP:', e.message); - } const tip = await mgr.chain.tipHeight(); const candidates = mgr.vtxos().filter((v) => v.state === 'spendable') .sort((a, b) => a.amountSat - b.amountSat); @@ -684,9 +602,9 @@ export function arkFeature(ctx) { }, 4000); return; } - // can't cover once fees are in — hand off to the Boltz path + // can't cover once fees are in ui.arkLnPay = null; - if (!ctx.hook('startLnPayBoltz', invoice, meta)) { ui.sendError = t('arkNotConnected'); } + ui.sendError = t('arkLnPayFailed'); render(); return; } @@ -702,21 +620,6 @@ export function arkFeature(ctx) { ui.busy = true; ui.sendError = ''; render(); try { const mgr = await connectArk(); - if (p.bridge) { - p.status = 'paying'; render(); - try { - await bridgePay(p.bridge, p.invoice); - ui.arkLnPaid = { amountSat: p.amountSat, meta: p.meta }; - ui.arkLnPay = null; - ui.busy = false; render(); - return; - } catch (e) { - // the bridge couldn't do it (and has refunded us) — fall back to - // the ASP rather than dead-ending the payment - console.warn('bridge swap failed, falling back to the ASP:', e.message); - p.bridge = null; p.status = 'ready'; - } - } const id = await mgr.payLnInvoice(p.invoice, { amountSat: sats }); const settle = (a) => { if (a.step === 'done') { @@ -777,7 +680,7 @@ export function arkFeature(ctx) { h('div', { style: 'display:flex;align-items:center' }, unitTag())) : null, p.amountSat != null ? row(t('lnPayAmount'), p.amountSat) : null, - p.feeSat != null && p.feeSat > 0 ? row(p.bridge ? t('arkLnBridgeFee') : t('arkLnFee'), p.feeSat) : null, + p.feeSat != null && p.feeSat > 0 ? row(t('arkLnFee'), p.feeSat) : null, p.amountSat != null && p.feeSat != null ? [h('div', { style: 'border-top:1px solid var(--line, #ddd);margin:2px 0' }), row(t('lnPayTotal'), total, true)] : null), @@ -985,41 +888,6 @@ export function arkFeature(ctx) { ); } - // Swap-bridge settings: an optional cheaper route for bolt11 sends. - function bridgeCard() { - const net = getNetwork(); - const id = getBridgeProviderId(net); - const presets = bridgePresets(net); - if (presets.length <= 1 || !arkAvailable()) return null; - return h('div', { class: 'card col' }, - h('h3', { class: 'row gap6', style: 'align-items:center' }, '⚡', t('arkBridgeTitle')), - h('p', { class: 'small muted', style: 'margin:0' }, t('arkBridgeDesc')), - h('select', { - onChange: (e) => { setBridgeProviderId(e.target.value, net); render(); }, - }, presets.map((p) => h('option', { value: p.id, selected: p.id === id }, p.label))), - id === 'custom' - ? h('label', { class: 'field' }, - h('span', { class: 'lab' }, t('arkBridgeUrl')), - h('input', { - type: 'text', class: 'mono-input', placeholder: 'https://swap.example.com', - autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', - value: getBridgeCustom(net), - onChange: (e) => { setBridgeCustom(e.target.value, net); render(); }, - })) - : null, - id !== 'off' - ? h('label', { class: 'field' }, - h('span', { class: 'lab' }, t('arkBridgeToken')), - h('input', { - type: 'password', class: 'mono-input', placeholder: t('arkBridgeTokenPh'), - autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', - value: getBridgeToken(net), - onChange: (e) => { setBridgeToken(e.target.value, net); render(); }, - })) - : null, - id !== 'off' ? h('div', { class: 'small faint' }, t('arkBridgeTrust')) : null); - } - async function doArkBoard() { const sats = parseInt((ui.arkBoardAmt || '').trim(), 10); if (!sats) return; @@ -1699,93 +1567,23 @@ export function arkFeature(ctx) { return { invoice: a.invoice, paymentHash: a.paymentHash, amountSat }; }, arkMovements() { const s = arkStateNow(); return s ? (s.movements || []) : []; }, - // endpoints + server identity + bridge for the NWC pouch record - arkPouchContext() { - const cfg = getArkConfig(); - if (!cfg || !ark || !ark.info) return null; - return { - arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: getNetwork(), - serverPubkey: ark.info.serverPubkey, bridge: getBridgeConfig(), - }; - }, - // Move sats INTO the pouch: an arkoor self-send to a chain-6 key whose - // signed recipient bytes we keep directly — no address, no mailbox. - async arkPouchFund(amountSat) { - const pouch = await loadPouch(wallet._cacheKey()); - if (!pouch) throw new Error('enable background answering first'); - const mgr = await connectArk(); - const idx = (pouch.fundSeq || 0) % POUCH_CHANGE_BASE; - const dest = pouchKey(wallet.account(), idx); - const out = await mgr.sendToPubkey(hex.encode(dest.pubkey), amountSat); - pouch.fundSeq = (pouch.fundSeq || 0) + 1; - const add = out.vtxos.map((v) => ({ ...v, keyIndex: idx, state: 'spendable' })); - pouch.vtxos = [...(pouch.vtxos || []), ...add]; - if (pouch.mgr) pouch.mgr.vtxos = [...(pouch.mgr.vtxos || []), ...add.map((v) => ({ ...v }))]; - pouch.updated = Date.now(); - await savePouch(wallet._cacheKey(), pouch); - return add.reduce((n, v) => n + v.amountSat, 0); - }, - // Everything back to the main wallet (ordinary arkoor sends to our own - // ark address, so they arrive like any receive). - async arkPouchEmpty() { - const pouch = await loadPouch(wallet._cacheKey()); - if (!pouch) return 0; - const pm = await pouchManager(pouch); - const mgr = await connectArk(); - const dest = mgr.address(); - let moved = 0; - for (let guard = 0; guard < 30; guard++) { - const v = (pm.state.vtxos || []).find((x) => x.state === 'spendable'); - if (!v) break; - await pm.send(dest, v.amountSat); // full amount: no change stays behind - moved += v.amountSat; - } - pouch.vtxos = (pm.state.vtxos || []).filter((x) => x.state === 'spendable'); - pouch.mgr = pm.state; - pouch.updated = Date.now(); - await savePouch(wallet._cacheKey(), pouch); - return moved; - }, - async arkPouchBalance() { - const pouch = await loadPouch(wallet._cacheKey()); - if (!pouch) return 0; - const vs = (pouch.mgr && pouch.mgr.vtxos) || pouch.vtxos || []; - return vs.filter((v) => (v.state || 'spendable') === 'spendable').reduce((n, v) => n + (v.amountSat || 0), 0); - }, - // Recover worker-initiated swaps: finished ones become history, dead ones - // are refunded with the POUCH's own keys (the refund pubkey is chain 6 — - // the main manager cannot sign for it). - async arkPouchRecover() { - const pouch = await loadPouch(wallet._cacheKey()); - if (!pouch || !(pouch.swaps || []).some((r) => ['submitted', 'refundable'].includes(r.step))) return; - const pm = await pouchManager(pouch); - for (const rec of pouch.swaps) { - if (!['submitted', 'refundable'].includes(rec.step)) continue; - try { - const st = await fetch(`${rec.bridgeUrl}/swap?paymentHash=${rec.paymentHash}`).then((r) => r.json()); - if (st.step === 'done' && st.preimage) { rec.step = 'done'; rec.preimage = st.preimage; continue; } - } catch {} - try { - const r = await fetch(`${rec.bridgeUrl}/refund`, { - method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ paymentHash: rec.paymentHash }), - }); - const out = await r.json(); - if (!r.ok || out.error) throw new Error(out.error || 'refund failed'); - await pm.acceptHtlcRefund({ vtxoBytesList: out.refundVtxos, refundIndex: rec.refundIndex }); - rec.step = 'refunded'; - } catch {} - } - pouch.mgr = pm.state; - await savePouch(wallet._cacheKey(), pouch); + // Whether background answering can mirror right now. + arkBgReady() { return !!(ark && ark.info && ark.state); }, + // nwc.js calls this (with its connections) whenever the mirror should + // refresh: on enable, on connection changes, after payments, on a timer. + async arkBgWrite(connections) { return writeBgMirror(connections); }, + async arkBgSpendableSat() { + const rec = await loadBg(wallet._cacheKey()); + return ((rec && rec.mgr && rec.mgr.vtxos) || []) + .filter((v) => v.state === 'spendable').reduce((n, v) => n + (v.amountSat || 0), 0); }, - // Pouch payments become ordinary history lines once the app opens. - async arkPouchNoteSpends(spends) { + // Worker payments become ordinary history lines once the app opens. + async arkBgNoteSpends(spends) { const mgr = await connectArk(); - for (const s of spends) { + for (const sp of spends) { mgr._movement({ - type: 'ln-send', amountSat: s.amountSat, status: 'complete', - detail: `via pouch · fee ${s.feeSat || 0} sat`, invoice: s.invoice || '', preimage: s.preimage || '', + type: 'ln-send', amountSat: sp.amountSat, status: 'complete', + detail: `while closed · fee ${sp.feeSat || 0} sat`, invoice: sp.invoice || '', preimage: sp.preimage || '', }); } mgr._save(); @@ -1872,7 +1670,7 @@ export function arkFeature(ctx) { : null, done ? null : h('div', { class: 'small muted', style: 'margin-top:2px' }, t('arkBoardedNote'))); }, - settingsCards() { return [arkCard(), bridgeCard()]; }, + settingsCards() { return [arkCard()]; }, // ---- ark-gift hooks (called by the gifts feature via ctx.hook) ---- arkGiftInfo() { diff --git a/src/features/index.js b/src/features/index.js index 06d8d4d..583334f 100644 --- a/src/features/index.js +++ b/src/features/index.js @@ -5,17 +5,16 @@ // bundle. See featureIndexSource() in build.js. import { giftsFeature } from './gifts.js'; -import { swapsFeature } from './swaps.js'; import { arkFeature } from './ark.js'; import { zapsFeature } from './zaps.js'; -import { spFeature } from './sp.js'; import { syncFeature } from './sync.js'; import { nwcFeature } from './nwc.js'; // NB order is meaningful where hooks stack: gifts' receive takeover and // balance line come before ark's, matching the pre-plugin layout. zaps sits // after ark so an npub prefers an instant Ark zap and only falls through to a -// Lightning zap when Ark can't serve it. +// Lightning zap when Ark can't serve it. nwc sits after ark because it +// drives ark's headless pay/balance seam. export function buildFeatures(ctx) { - return [giftsFeature(ctx), swapsFeature(ctx), arkFeature(ctx), zapsFeature(ctx), spFeature(ctx), nwcFeature(ctx), syncFeature(ctx)]; + return [giftsFeature(ctx), arkFeature(ctx), zapsFeature(ctx), nwcFeature(ctx), syncFeature(ctx)]; } diff --git a/src/features/nwc.js b/src/features/nwc.js index a91768d..f7e42c8 100644 --- a/src/features/nwc.js +++ b/src/features/nwc.js @@ -7,10 +7,8 @@ // at any time. // // hal holds no native Lightning balance, so every Lightning operation here -// runs over Ark: paying goes through the swap bridge when one is configured -// and falls back to the ASP's own lightning-send, and receiving mints an -// invoice on the ASP. That means NWC availability tracks Ark availability — -// no Ark server, no wallet service. +// runs over Ark: the ASP pays and mints invoices. That means NWC +// availability tracks Ark availability — no Ark server, no wallet service. // // Shape of the protocol (mirrors coinos-server/lib/nwc.ts): // kind 13194 info event, content = space-separated supported methods @@ -18,8 +16,9 @@ // kind 23195 response (wallet -> client), same encryption scheme back // Encryption is nip04 unless the request carries `["encryption","nip44_v2"]`. // -// IMPORTANT limitation, surfaced in the UI: hal is a web wallet, so it only -// answers while a tab is open. It is not an always-on wallet service. +// While a tab is open it answers directly; with background answering on, +// the service worker answers from a mirrored state (see nwc-bg.js) so the +// wallet keeps working closed. import { hex } from '@scure/base'; import { sha256 } from '@noble/hashes/sha256'; @@ -30,7 +29,7 @@ import { import { t } from '../i18n.js'; import { qrSvg } from '../qr.js'; import { getNetwork } from '../api.js'; -import { buildPouch, loadPouch, savePouch, clearPouch, pouchKey } from '../nwc-pouch.js'; +import { loadBg, saveBg, clearBg } from '../nwc-bg.js'; import { maybeBolt11 } from '../ark/lightning.js'; const REQ_KIND = 23194; @@ -82,6 +81,7 @@ export function nwcFeature(ctx) { // travels — and the payment just changed the ark state (vtxos, movement) // that the user's other devices display. try { wallet.saveCache(); } catch {} + writeBg(); // keep the worker's mirror as fresh as the state it spends } // The relays advertised in the URI MUST be the ones we listen on, or a @@ -449,7 +449,7 @@ export function nwcFeature(ctx) { }); if (!r.ok) throw new Error(`notifier refused: ${r.status}`); const st = load(); st.background = true; save(st); - await writePouch(); + writeBg(); return true; } @@ -493,56 +493,45 @@ export function nwcFeature(ctx) { const st = load(); st.background = false; save(st); // no background answering means no reason to keep spendable keys where a // worker can read them - try { await clearPouch(wallet._cacheKey()); } catch {} + try { await clearBg(wallet._cacheKey()); } catch {} } - // (Re)write the pouch record the worker answers from: connections' service - // keys, ASP + bridge endpoints, the chain-6 key window — while PRESERVING - // anything the pouch already holds (its coins, the worker's spend log and - // manager state), which a rebuild must never wipe. - async function writePouch() { + // (Re)write the background record the worker answers from: the full ark + // state mirror plus this feature's connections. The ark feature owns the + // heavy lifting; this decides WHEN (background answering on). + let bgTimer = null; + function writeBg() { if (!load().background) return; - const cfg = hook('arkPouchContext'); - if (!cfg) return; - try { - const prev = await loadPouch(wallet._cacheKey()); - const fresh = buildPouch({ - ...cfg, vtxos: prev?.vtxos || [], account: wallet.account(), connections: conns(), - }); - if (prev) { - fresh.mgr = prev.mgr; - fresh.spends = prev.spends; - fresh.swaps = prev.swaps; - fresh.fundSeq = prev.fundSeq; - if (prev.mgr?.nextKeyIndex) fresh.nextKeyIndex = prev.mgr.nextKeyIndex; - } - await savePouch(wallet._cacheKey(), fresh); - pouchSat = await hook('arkPouchBalance'); - } catch (e) { console.warn('nwc: could not write pouch', e.message); } + clearTimeout(bgTimer); + bgTimer = setTimeout(async () => { + try { + if (!hook('arkBgReady')) return; + await hook('arkBgWrite', conns()); + } catch (e) { console.warn('nwc: could not write background state', e.message); } + }, 1500); } // When the app opens after the worker has been answering: absorb its spends - // into the budgets and history, and rescue any swap it left hanging. - let pouchSat = null; // cached for the (synchronous) settings card - async function reconcilePouch() { + // into the budgets and history. (The ark feature separately merges the + // worker's coin state on connect.) + async function reconcileBg() { try { if (!load().background) return; - await hook('arkPouchRecover'); - const pouch = await loadPouch(wallet._cacheKey()); - if (!pouch) return; - const fresh = (pouch.spends || []).filter((s) => !s.absorbed); + const rec = await loadBg(wallet._cacheKey()); + if (!rec) return; + const fresh = (rec.spends || []).filter((s) => !s.absorbed); if (fresh.length) { for (const s of fresh) { const c = conns().find((x) => x.id === s.connId); if (c) recordSpend(c, (s.amountSat || 0) + (s.feeSat || 0)); s.absorbed = true; } - await hook('arkPouchNoteSpends', fresh); - await savePouch(wallet._cacheKey(), pouch); + await hook('arkBgNoteSpends', fresh); + await saveBg(wallet._cacheKey(), rec); } - pouchSat = await hook('arkPouchBalance'); + writeBg(); // refresh the mirror after the merge settled render(); - } catch (e) { console.warn('nwc: pouch reconcile failed', e.message); } + } catch (e) { console.warn('nwc: background reconcile failed', e.message); } } // An open window handles the request itself; the worker just nudges it. @@ -561,7 +550,7 @@ export function nwcFeature(ctx) { let watchdog = null; function startWatchdog() { if (watchdog) return; - watchdog = setInterval(() => { if (conns().length) listen(); }, 90 * 1000); + watchdog = setInterval(() => { if (conns().length) { listen(); writeBg(); } }, 90 * 1000); if (typeof window !== 'undefined') { window.addEventListener('online', () => listen()); document.addEventListener('visibilitychange', () => { @@ -615,42 +604,11 @@ export function nwcFeature(ctx) { h('button', { class: 'btn-sm', onClick: async () => { try { if (load().background) { await disableBackground(); toast(t('nwcBackgroundOff')); } - else { await enableBackground(); await reconcilePouch(); toast(t('nwcBackgroundOn')); } + else { await enableBackground(); await reconcileBg(); toast(t('nwcBackgroundOn')); } } catch (e) { toast(e.message); } render(); } }, load().background ? t('nwcOn') : t('nwcOff'))) : null, - list.length && load().background - ? h('div', { class: 'col', style: 'gap:6px' }, - h('div', { class: 'row between' }, - h('span', { class: 'small' }, t('nwcPouch')), - h('span', { class: 'small' }, pouchSat == null ? '…' : fmtAmount(pouchSat))), - h('p', { class: 'small faint', style: 'margin:0' }, t('nwcPouchDesc')), - h('div', { class: 'row gap6' }, - h('input', { type: 'number', min: '1', placeholder: t('nwcPouchAmount'), value: ui.nwcPouchAmt || '', - style: 'flex:1', onInput: (e) => { ui.nwcPouchAmt = e.target.value; } }), - h('button', { class: 'btn-sm', onClick: async () => { - const sat = parseInt(ui.nwcPouchAmt, 10); - if (!sat) return; - try { - const moved = await hook('arkPouchFund', sat); - ui.nwcPouchAmt = ''; - pouchSat = await hook('arkPouchBalance'); - toast(t('nwcPouchFunded', { sats: fmtAmount(moved) })); - } catch (e) { toast(e.message); } - render(); - } }, t('nwcPouchFund')), - pouchSat > 0 - ? h('button', { class: 'btn-ghost btn-sm', onClick: async () => { - try { - const moved = await hook('arkPouchEmpty'); - pouchSat = await hook('arkPouchBalance'); - toast(t('nwcPouchEmptied', { sats: fmtAmount(moved) })); - } catch (e) { toast(e.message); } - render(); - } }, t('nwcPouchEmpty')) - : null)) - : null, st ? h('div', { class: 'col', style: 'gap:6px;border-top:1px solid var(--border,rgba(128,128,128,.2));padding-top:8px' }, h('input', { type: 'text', placeholder: t('nwcAppName'), value: st.name, @@ -677,7 +635,7 @@ export function nwcFeature(ctx) { return { id: 'nwc', - init() { listen(); startWatchdog(); refreshRegistration(); reconcilePouch(); }, + init() { listen(); startWatchdog(); refreshRegistration(); reconcileBg(); }, stop() { stop(); if (watchdog) { clearInterval(watchdog); watchdog = null; } }, settingsCards() { return [nwcCard()]; }, }; diff --git a/src/features/sp-wallet.js b/src/features/sp-wallet.js deleted file mode 100644 index f461d27..0000000 --- a/src/features/sp-wallet.js +++ /dev/null @@ -1,544 +0,0 @@ -// Silent payments (BIP-352) wallet engine — key derivation, indexer scanning -// (worker-accelerated with an inline fallback), the live indexer WebSocket, -// SP outputs as spendable coins, sp1 send preparation, and per-output-key -// signing. Installed onto the core wallet by the sp feature, wired through the -// wallet's generic seams so a build without sp ships none of this. - -import * as btc from '@scure/btc-signer'; -import { hex } from '@scure/base'; -import { schnorr } from '@noble/curves/secp256k1'; -import { HDKey } from '@scure/bip32'; -import { mnemonicToSeedSync } from '@scure/bip39'; -import { - isSilentPaymentAddress, decodeSilentPaymentAddress, silentPaymentScripts, - silentPaymentPlaceholder, deriveSilentPaymentKeys, encodeSilentPaymentAddress, - silentPaymentScan, silentPaymentOutputPrivKey, silentPaymentCandidate, bloomHas, -} from '../silentpay.js'; -import { spIndexerUrl } from '../api.js'; -import { utxoId, absWsUrl, NETS } from '../wallet.js'; - -const SP_DUST_LIMIT = 0; - -export function installSpWallet(wallet) { - if (wallet.scanSilentPayments) return; // already installed - wallet.spUtxos = []; - wallet.lastSpScan = 0; // highest block height scanned for silent payments - wallet.spScanning = false; - - Object.assign(wallet, { - // BIP-352 scan/spend keys, derived from the master seed (m/352' paths) — so - // they need the mnemonic (or a depth-0 master xprv); watch-only and account- - // level imported keys can't do silent payments. Cached per account. - silentPaymentKeys() { - if (this.watchOnly) return null; - const coin = NETS[this.netName].coin; - const ck = `sp|${this.netName}|${this.mnemonic}|${this.passphrase}|${this.xprv}`; - if (this._spKeys && this._spKeysKey === ck) return this._spKeys; - let master = null; - if (this.mnemonic) master = HDKey.fromMasterSeed(mnemonicToSeedSync(this.mnemonic, this.passphrase)); - else if (this.xprv) { const n = HDKey.fromExtendedKey(this.xprv); if (n.depth === 0) master = n; } - this._spKeys = master ? deriveSilentPaymentKeys(master, coin) : null; - this._spKeysKey = ck; - this._spScanCache = new Map(); // per-tweak scan results are keyed to these keys - this._spCandCache = new Map(); - return this._spKeys; - }, - - // Scan one tweak's outputs for our payments, memoized by tweak — the EC - // point-multiplication is the cost, and a tweak's outcome never changes, so - // repeated mempool pushes (which re-send the same tweaks) stay cheap. - _spScan(tweakHex, outputs) { - const keys = this.silentPaymentKeys(); // resets the caches if keys changed - if (!keys) return []; - let m = this._spScanCache.get(tweakHex); - if (m) return m; - if (this._spScanCache.size > 100000) this._spScanCache.clear(); // bound memory over long sessions - m = silentPaymentScan({ scanPriv: keys.scanPriv, spendPub: keys.spendPub, tweak: hex.decode(tweakHex), outputs }); - this._spScanCache.set(tweakHex, m); - return m; - }, - - // Our candidate key for a tweak (for the Bloom pre-filter), memoized likewise. - _spCandidate(tweakHex) { - const keys = this.silentPaymentKeys(); - if (!keys) return null; - let c = this._spCandCache.get(tweakHex); - if (c) return c; - if (this._spCandCache.size > 100000) this._spCandCache.clear(); // bound memory over long sessions - c = silentPaymentCandidate({ scanPriv: keys.scanPriv, spendPub: keys.spendPub, tweak: hex.decode(tweakHex) }); - this._spCandCache.set(tweakHex, c); - return c; - }, - - // Spawn (once) the scanning worker and keep its keys current. Returns null if - // workers are unavailable or the embedded bundle is missing — callers then fall - // back to scanning on the main thread (still cached + yielding). - _ensureSpWorker() { - if (this._spWorkerFailed || typeof Worker === 'undefined') return null; - const keys = this.silentPaymentKeys(); - if (!keys) return null; - if (!this._spWorker) { - try { - const b64 = globalThis.__SP_WORKER__; - if (!b64) { this._spWorkerFailed = true; return null; } - const bytes = Uint8Array.from(atob(b64), (ch) => ch.charCodeAt(0)); - const w = new Worker(URL.createObjectURL(new Blob([bytes], { type: 'text/javascript' }))); - this._spPending = new Map(); - this._spReqId = 0; - w.onmessage = (e) => { const p = this._spPending.get(e.data.id); if (p) { this._spPending.delete(e.data.id); e.data.error ? p.rej(new Error(e.data.error)) : p.res(e.data); } }; - w.onerror = () => { this._spWorkerFailed = true; try { w.terminate(); } catch {} this._spWorker = null; for (const p of this._spPending.values()) p.rej(new Error('sp worker error')); this._spPending.clear(); }; - this._spWorker = w; - this._spWorkerKeysSig = null; - } catch { this._spWorkerFailed = true; return null; } - } - if (this._spWorkerKeysSig !== this._spKeysKey) { - this._spWorkerKeysSig = this._spKeysKey; - this._spWorker.postMessage({ id: ++this._spReqId, op: 'keys', scanPriv: keys.scanPriv, spendPub: keys.spendPub }); - } - return this._spWorker; - }, - - _spCall(op, data) { - return new Promise((res, rej) => { - const id = ++this._spReqId; - this._spPending.set(id, { res, rej }); - this._spWorker.postMessage({ id, op, ...data }); - setTimeout(() => { - if (!this._spPending.has(id)) return; - // Timed out. TERMINATE the worker rather than let it keep grinding the - // orphaned request in the background — otherwise the caller's inline - // fallback runs the SAME work on the main thread and both threads peg. - // _ensureSpWorker() respawns a fresh worker (and re-sends keys) on the - // next call, so this only costs us the current chunk. With the tweak - // budget above, calls stay sub-second and this should never fire. - try { this._spWorker && this._spWorker.terminate(); } catch {} - this._spWorker = null; - for (const p of this._spPending.values()) p.rej(new Error('sp worker timeout')); - this._spPending.clear(); - }, 30000); - }); - }, - - // Block heights whose Bloom filter might hold one of our outputs (worker if - // available, else a cached + yielding main-thread loop). - async _spHitBlocks(blocks) { - if (!blocks.length) return new Set(); - const w = this._ensureSpWorker(); - if (w) { try { return new Set((await this._spCall('candidates', { blocks })).hits || []); } catch {} } - const hits = new Set(); - let i = 0; - for (const b of blocks) { - if (++i % 8 === 0) await new Promise((r) => setTimeout(r, 0)); - for (const tw of b.tweaks) { if (bloomHas(b.filter, this._spCandidate(tw))) { hits.add(b.height); break; } } - } - return hits; - }, - - // Full-scan items → our resolved utxos {txid,vout,value,xonly,tweak} (worker if - // available, else a cached + yielding main-thread loop). - async _spScanItems(items) { - if (!items.length) return []; - const w = this._ensureSpWorker(); - if (w) { try { return (await this._spCall('scan', { items })).found || []; } catch {} } - const found = []; - let i = 0; - for (const it of items) { - if (++i % 50 === 0) await new Promise((r) => setTimeout(r, 0)); - const matches = this._spScan(it.tweak, it.outputs.map((o) => o.xonly)); - for (const m of matches) { const o = it.outputs.find((x) => x.xonly === m.output); if (o) found.push({ txid: it.txid, vout: o.vout, value: o.value, xonly: m.output, tweak: m.tweak }); } - } - return found; - }, - - // Our silent-payment address (sp1…/tsp1…), or null if this wallet can't do SP. - silentPaymentAddress() { - const k = this.silentPaymentKeys(); - return k ? encodeSilentPaymentAddress(k.scanPub, k.spendPub, { testnet: this.netName !== 'mainnet' }) : null; - }, - - // Is SP receiving available here? (we can derive keys AND an indexer is set) - silentPaymentsAvailable() { - return !!(this.silentPaymentKeys() && spIndexerUrl(this.netName)); - }, - - // Total received via silent payments: { confirmed, pending, count } over the - // unspent SP outputs we've found. - spBalance() { - let confirmed = 0, pending = 0; - for (const u of this.spUtxos) { if (u.confirmed) confirmed += u.value; else pending += u.value; } - return { confirmed, pending, count: this.spUtxos.length }; - }, - - // Received silent payments as history rows (one per tx; multiple outputs to us - // sum into one received entry) so they appear in history like normal payments. - spTxRows() { - const byTx = new Map(); - for (const u of this.spUtxos) { - const e = byTx.get(u.txid) || { txid: u.txid, net: 0, confirmed: true, firstSeen: u.firstSeen || Date.now() }; - e.net += u.value; - if (!u.confirmed) e.confirmed = false; - e.firstSeen = Math.min(e.firstSeen, u.firstSeen || e.firstSeen); - byTx.set(u.txid, e); - } - return [...byTx.values()].map((e) => ({ - txid: e.txid, net: e.net, fee: 0, vsize: 0, sp: true, - confirmed: e.confirmed, - blockTime: e.confirmed ? Math.floor(e.firstSeen / 1000) : 0, - blockHeight: 0, - firstSeen: e.firstSeen, - })); - }, - - // The taproot address for an x-only output key (for backend UTXO lookups). - _spAddress(xonly) { - return btc.Address(this.netCfg.net).encode(btc.OutScript.decode(btc.OutScript.encode({ type: 'tr', pubkey: hex.decode(xonly) }))); - }, - - // Scan the configured SP indexer from the last-scanned height to the tip, find - // outputs paying our scan/spend keys, and verify each is unspent via the chain - // backend (the indexer only does discovery). Updates this.spUtxos. - async scanSilentPayments({ rescan = false, silent = false } = {}) { - const keys = this.silentPaymentKeys(); - const indexer = spIndexerUrl(this.netName); - if (!keys || !indexer || this.offline) return { unavailable: true }; - if (this._spScanBusy) return { busy: true }; - // Fresh wallet, never scanned: set the watermark to the current tip instead of - // scanning all history (nothing predates the wallet). New blocks + mempool - // arrive via the live push from here on. - if (this._spFresh && !this.lastSpScan && !rescan) { - this._spFresh = false; - try { this.lastSpScan = (await (await fetch(`${indexer}/height`)).json()).height || 0; this.saveCache(); } catch {} - return { fresh: true }; - } - // Two tabs on the same wallet must not both grind the catch-up: a - // localStorage lease (refreshed per chunk) lets the second tab yield. - const leaseKey = this._cacheKey() + ':spscan-lease'; - try { - if (!rescan && Date.now() - (parseInt(localStorage.getItem(leaseKey), 10) || 0) < 30000) { - return { busy: true }; - } - localStorage.setItem(leaseKey, String(Date.now())); - } catch {} - this._spScanBusy = true; - // Only a manual scan shows the visible "Scanning…" state; auto-scans are - // invisible (setting spScanning for them left the card stuck on "Scanning…"). - if (!silent) { this.spScanning = true; this.emit(); } - try { - if (rescan) { this.spUtxos = []; this.lastSpScan = 0; } - const from = (this.lastSpScan || 0) + 1; - // Catch-up: per block we get tweaks + a Bloom filter (no outputs). Derive a - // candidate key per tweak, test the filter, and only fetch the full block on - // a hit — so most blocks cost just the candidate math, no download/scan. - const res = await (await fetch(`${indexer}/scan/${from}?dustLimit=${SP_DUST_LIMIT}`)).json(); - const tip = res.tip || 0; - const have = new Set(this.spUtxos.map((u) => `${u.txid}:${u.vout}`)); - const found = []; - const blocks = res.blocks || []; - // Process in chunks bounded by TWEAK count, not block count. The cost is - // one EC point-multiply per tweak, and mainnet blocks are wildly uneven - // (a few to 2500+ tweaks each). A fixed 100-block chunk could be ~25k - // tweaks — seconds of work that blew the 30s worker timeout, which then - // dropped the same chunk onto the main thread while the orphaned worker - // kept grinding it (both threads pegged). Budgeting by tweaks keeps every - // worker call sub-second regardless of block density; the watermark still - // advances per chunk so interruptions resume instead of restarting. - const TWEAK_BUDGET = 2000; - for (let c = 0; c < blocks.length; ) { - // grow the slice until it holds ~TWEAK_BUDGET tweaks (always ≥1 block, - // so a single dense block still goes as its own chunk). - let end = c, tw = 0; - do { tw += (blocks[end].tweaks || []).length; end++; } while (end < blocks.length && tw < TWEAK_BUDGET); - const slice = blocks.slice(c, end); - c = end; - const hits = await this._spHitBlocks(slice); - for (const block of slice) { - if (!hits.has(block.height)) continue; - const blk = await (await fetch(`${indexer}/block/${block.height}`)).json(); - for (const f of await this._spScanItems(blk.items || [])) { - const id = `${f.txid}:${f.vout}`; - if (have.has(id)) continue; - have.add(id); - found.push(f); - } - } - const maxH = slice[slice.length - 1].height; - if (maxH > (this.lastSpScan || 0)) { this.lastSpScan = maxH; this.saveCache(); } // checkpoint - try { localStorage.setItem(leaseKey, String(Date.now())); } catch {} // keep the lease - await new Promise((r) => setTimeout(r, 0)); // let the UI breathe - } - if (tip > (this.lastSpScan || 0)) this.lastSpScan = tip; - // Verify newly-found outputs are unspent + get confirmation status. - for (const f of found) { - const address = this._spAddress(f.xonly); - try { - const us = await this.api.addressUtxos(address); - const u = us.find((x) => x.txid === f.txid && x.vout === f.vout); - if (u) this.spUtxos.push({ ...f, address, confirmed: !!(u.status && u.status.confirmed), firstSeen: Date.now() }); - } catch {} - } - await this._refreshSpUtxos(); - this._recomputeBalanceFromChains(); // fold found SP value into the displayed balance - this.saveCache(); - return { found: found.length, scanned: tip }; - } finally { - this._spScanBusy = false; - this.spScanning = false; - this.emit(); // always refresh (balance + clears any "Scanning…") - } - }, - - // Re-check tracked SP outputs against the backend: drop spent ones, refresh - // confirmation status. - async _refreshSpUtxos() { - const next = []; - for (const u of this.spUtxos) { - try { - const us = await this.api.addressUtxos(u.address); - const live = us.find((x) => x.txid === u.txid && x.vout === u.vout); - if (live) next.push({ ...u, confirmed: !!(live.status && live.status.confirmed) }); - } catch { next.push(u); } // backend hiccup: keep as-is - } - this.spUtxos = next; - }, - - // Live silent-payment push: subscribe to the indexer's WebSocket so each new - // block's SP items are scanned on arrival instead of polling. Catches up via a - // /scan on connect (covers anything missed while disconnected) and reconnects. - _spConnect() { - if (this.offline) return; - const base = spIndexerUrl(this.netName); - if (!base || !this.silentPaymentKeys()) return; - const url = base.replace(/^http/, 'ws') + '/ws'; - if (this._spWs && this._spWsUrl === url) return; - this._spDisconnect(); - this._spWsUrl = url; - let ws; - try { ws = new WebSocket(absWsUrl(url)); } catch { return; } - this._spWs = ws; - ws.onopen = () => { - this.spLive = true; - this.scanSilentPayments({ silent: true }).catch(() => {}); // confirmed catch-up - // Pending (mempool) catch-up. - fetch(`${base}/mempool`).then((r) => r.json()).then((d) => this._scanSpMempool(d.items || [])).catch(() => {}); - }; - ws.onmessage = (ev) => { - let msg; - try { msg = JSON.parse(typeof ev.data === 'string' ? ev.data : new TextDecoder().decode(ev.data)); } catch { return; } - if (!msg || !Array.isArray(msg.items)) return; - if (msg.type === 'block') this._scanSpItems(msg.items, msg.height).catch(() => {}); - else if (msg.type === 'mempool') this._scanSpMempool(msg.items).catch(() => {}); - }; - ws.onerror = () => { try { ws.close(); } catch {} }; - ws.onclose = () => { - if (this._spWs !== ws) return; - this.spLive = false; this._spWs = null; - if (!this.offline) this._spReconnect = setTimeout(() => this._spConnect(), 3000); - }; - }, - - _spDisconnect() { - clearTimeout(this._spReconnect); - this.spLive = false; - const ws = this._spWs; - this._spWs = null; this._spWsUrl = null; - if (ws) { try { ws.onclose = null; ws.close(); } catch {} } - }, - - // Scan a confirmed block's pushed items and upsert ours into spUtxos as - // confirmed (a previously-pending mempool entry is promoted to confirmed). - async _scanSpItems(items, height) { - const keys = this.silentPaymentKeys(); - if (!keys) return; - let changed = false; - for (const f of await this._spScanItems(items)) { - const existing = this.spUtxos.find((u) => u.txid === f.txid && u.vout === f.vout); - if (existing) { if (!existing.confirmed) { existing.confirmed = true; changed = true; } } - else { this.spUtxos.push({ txid: f.txid, vout: f.vout, value: f.value, xonly: f.xonly, tweak: f.tweak, address: this._spAddress(f.xonly), confirmed: true, firstSeen: Date.now() }); changed = true; } - } - if (height && height > this.lastSpScan) this.lastSpScan = height; - if (changed) this._recomputeBalanceFromChains(); - this.saveCache(); - if (changed) this.emit(); - }, - - // Scan the current mempool's pushed items and rebuild our pending (unconfirmed) - // SP set — so payments show the moment they're broadcast. Confirmed entries are - // kept; the unconfirmed set is replaced, which also drops vanished/RBF'd ones. - async _scanSpMempool(items) { - // Coalesce: pushes arrive faster than a cold (mainnet) scan completes, so - // keep only the latest set and never run two scans at once. - this._spMempoolLatest = items; - if (this._spMempoolBusy) return; - this._spMempoolBusy = true; - try { - if (!this.silentPaymentKeys()) return; - while (this._spMempoolLatest) { - const cur = this._spMempoolLatest; - this._spMempoolLatest = null; - const priorSeen = new Map(this.spUtxos.map((u) => [`${u.txid}:${u.vout}`, u.firstSeen])); - const confirmedIds = new Set(this.spUtxos.filter((u) => u.confirmed).map((u) => `${u.txid}:${u.vout}`)); - const pending = []; - for (const f of await this._spScanItems(cur)) { - const id = `${f.txid}:${f.vout}`; - if (confirmedIds.has(id)) continue; - pending.push({ txid: f.txid, vout: f.vout, value: f.value, xonly: f.xonly, tweak: f.tweak, address: this._spAddress(f.xonly), confirmed: false, firstSeen: priorSeen.get(id) || Date.now() }); - } - // Re-read confirmed (a block push may have landed during a yield) and drop - // any pending that just confirmed, so an outpoint never appears twice. - const confNow = this.spUtxos.filter((u) => u.confirmed); - const confNowIds = new Set(confNow.map((u) => `${u.txid}:${u.vout}`)); - const oldPendingIds = new Set(this.spUtxos.filter((u) => !u.confirmed).map((u) => `${u.txid}:${u.vout}`)); - const freshPending = pending.filter((p) => !confNowIds.has(`${p.txid}:${p.vout}`)); - // Only emit when our pending set actually changed — the mempool churns - // constantly, and a no-op emit would re-render and steal input focus. - const changed = freshPending.length !== oldPendingIds.size || freshPending.some((p) => !oldPendingIds.has(`${p.txid}:${p.vout}`)); - this.spUtxos = [...confNow, ...freshPending]; - if (changed) { this._recomputeBalanceFromChains(); this.saveCache(); this.emit(); } - } - } finally { - this._spMempoolBusy = false; - } - }, - - // Mark summary outputs that fund a silent payment with the sp1… address the - // user actually typed (the on-chain output is a derived one-time taproot addr). - _tagSilentOutputs(summary, spOuts) { - for (const s of spOuts) { - if (!s.derivedAddress) continue; - const o = summary.outputs.find((o) => o.address === s.derivedAddress && !o.silent); - if (o) o.silent = s.address; - } - }, - - // Replace each placeholder silent-payment output in a freshly-selected (still - // unsigned) tx with its real one-time taproot script, derived from the tx's - // actual inputs per BIP-352. - _applySilentPayments(tx, spOuts) { - const byOutpoint = new Map(); - for (const u of this.utxos) byOutpoint.set(utxoId(u), { u, sp: false }); - for (const u of this.spUtxos) byOutpoint.set(utxoId(u), { u, sp: true }); - const spKeys = this.silentPaymentKeys(); - const inputs = []; - for (let i = 0; i < tx.inputsLength; i++) { - const inp = tx.getInput(i); - const e = byOutpoint.get(`${hex.encode(inp.txid)}:${inp.index}`); - if (!e) throw new Error('Missing key for a silent payment input.'); - // Our own SP coins can fund a silent payment too — they're taproot inputs - // whose key is d = spend_priv + t_k (committed to with even Y by the script). - if (e.sp) { - if (!spKeys) throw new Error('Missing keys for a silent payment input.'); - inputs.push({ txid: e.u.txid, vout: e.u.vout, priv: silentPaymentOutputPrivKey(spKeys.spendPriv, hex.decode(e.u.tweak)), taproot: true }); - } else { - inputs.push({ txid: e.u.txid, vout: e.u.vout, priv: this.node(e.u.chain, e.u.index).privateKey }); - } - } - const scripts = silentPaymentScripts(inputs, spOuts.map((s) => ({ scan: s.scan, spend: s.spend }))); - spOuts.forEach((s, k) => { - const ph = hex.encode(s.placeholder); - const idx = tx.outputs.findIndex((o) => hex.encode(o.script) === ph); - if (idx < 0) throw new Error('Silent payment output not found after selection.'); - tx.updateOutput(idx, { script: scripts[k], amount: tx.outputs[idx].amount }, true); - s.derivedAddress = btc.Address(this.netCfg.net).encode(btc.OutScript.decode(scripts[k])); - }); - }, - }); - - // -- seam registrations ----------------------------------------------- - wallet.registerBalanceExtra(() => wallet.spBalance()); - wallet.registerHistoryProvider(() => wallet.spTxRows()); - wallet.registerScanHook(() => wallet.scanSilentPayments({ silent: true }).catch(() => {})); - wallet.registerRealtimeHook({ - start: () => wallet._spConnect(), // live push of silent-payment receipts - stop: () => wallet._spDisconnect(), - // WS is primary; the heartbeat poll is the fallback when it isn't live. - poll: () => { if (!wallet.spLive) wallet.scanSilentPayments({ silent: true }).catch(() => {}); }, - }); - wallet.registerLoadHook(({ spFresh }) => { - // A freshly-generated wallet can't have received silent payments before it - // existed, so its SP watermark starts at the current tip (no history scan). - wallet._spFresh = !!spFresh; - wallet.spUtxos = []; - wallet.lastSpScan = 0; - }); - // Ride the encrypted sync snapshot so the (expensive) SP block catch-up runs - // ONCE across all devices instead of on each. Like Ark, this is a commutative - // merge (mergeAlways) — applied even for snapshots older than local state — so - // whichever device scans furthest carries the rest. - wallet.registerCacheExtension({ - mergeAlways: true, - save: () => ({ spUtxos: wallet.spUtxos, lastSpScan: wallet.lastSpScan }), - load: (d) => { - let grew = false; - // Watermark is a HIGH-water mark: adopt the furthest height any device - // reached, never regress. This is what stops every device re-grinding the - // ~1M-tweak backlog. (A manual rescan forces from=1 synchronously, so it - // still re-scans everything regardless of this.) - const remoteScan = d.lastSpScan || 0; - if (remoteScan > (wallet.lastSpScan || 0)) wallet.lastSpScan = remoteScan; - else if ((wallet.lastSpScan || 0) > remoteScan) grew = true; // our watermark is ahead - // Additive union of found coins by outpoint — bring in ones we lack, but - // NEVER mutate a coin we already track. Spend detection is each device's - // own job (_refreshSpUtxos drops coins the backend reports spent), so a - // stale coin merged in from another device self-heals on the next refresh - // rather than needing spent-state to propagate. - if (Array.isArray(d.spUtxos)) { - const have = new Set(wallet.spUtxos.map((u) => `${u.txid}:${u.vout}`)); - let added = false; - for (const ru of d.spUtxos) { - if (!ru || ru.txid == null || ru.vout == null) continue; - const id = `${ru.txid}:${ru.vout}`; - if (!have.has(id)) { wallet.spUtxos.push(ru); have.add(id); added = true; } - } - if (wallet.spUtxos.length > d.spUtxos.length) grew = true; // we hold coins it lacked - if (added) wallet._recomputeBalanceFromChains(); - } - // Anti-entropy: if we ended up knowing more than this snapshot did, push - // our superset back so the shared slot converges. Guarded, so once every - // device agrees it stops (matches the Ark merge). - if (grew) { try { wallet.saveCache(); } catch {} } - }, - }); - // Confirmed SP coins are spendable like any other coin — including to fund a - // silent payment. Unconfirmed ones are excluded (the sender's tx could be - // replaced, invalidating our spend). - wallet.registerInputProvider(({ coinIds }) => { - const confirmedSp = wallet.spUtxos.filter((u) => u.confirmed); - const pool = coinIds ? confirmedSp.filter((u) => coinIds.includes(utxoId(u))) : confirmedSp; - return pool.map((u) => ({ - txid: u.txid, - index: u.vout, - sequence: 0xfffffffd, - witnessUtxo: { script: btc.OutScript.encode({ type: 'tr', pubkey: hex.decode(u.xonly) }), amount: BigInt(u.value) }, - // Present so the fee estimator sizes a key-path (64-byte) witness; the - // value is unused (SP inputs are signed raw below, via tapKeySig). - tapInternalKey: hex.decode(u.xonly), - })); - }); - // sp1… recipients: the real taproot output commits to the final input set, - // so buildTx selects against a placeholder and swaps the derived script in. - wallet.registerSendPreparer(() => { - const spOuts = []; // { placeholder, scan, spend, address } - return { - match: (addr) => isSilentPaymentAddress(addr), - prepare(addr) { - if (wallet.watchOnly) throw new Error('Silent payments require a wallet with keys.'); - const { scan, spend } = decodeSilentPaymentAddress(addr); - const placeholder = silentPaymentPlaceholder(spOuts.length); - spOuts.push({ placeholder, scan, spend, address: addr }); - return placeholder; - }, - apply(tx) { if (spOuts.length) wallet._applySilentPayments(tx, spOuts); }, - tag(summary) { wallet._tagSilentOutputs(summary, spOuts); }, - }; - }); - // Received SP outputs sign with d = spend_priv + t_k (raw output key — - // no BIP-341 tweak, so btc-signer's auto key-path signing doesn't apply). - wallet.registerInputSigner((tx, i, id, { prevScripts, amounts }) => { - const u = wallet.spUtxos.find((x) => utxoId(x) === id); - if (!u) return false; - const d = silentPaymentOutputPrivKey(wallet.silentPaymentKeys().spendPriv, hex.decode(u.tweak)); - const hash = tx.preimageWitnessV1(i, prevScripts, btc.SigHash.DEFAULT, amounts); - tx.updateInput(i, { tapKeySig: schnorr.sign(hash, d) }, true); - return true; - }); -} diff --git a/src/features/sp.js b/src/features/sp.js deleted file mode 100644 index 92f091f..0000000 --- a/src/features/sp.js +++ /dev/null @@ -1,66 +0,0 @@ -// 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 { installSpWallet } from './sp-wallet.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; - installSpWallet(wallet); // the BIP-352 engine lives outside the core wallet - - 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'), icon: '🕶️', - 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); }, - // Silent-payment receipts merge into the main history as on-chain-looking - // rows (tx.sp) — give them a rail glyph + tag so they're recognizable. - decorateTxRow(tx) { - return tx && tx.sp - ? { icon: '🕶️', label: h('span', {}, t('received'), ' ', h('span', { class: 'tag' }, t('receiveSpTab'))) } - : null; - }, - settingsCards() { return [spIndexerCard()]; }, - }; -} diff --git a/src/features/swaps.js b/src/features/swaps.js deleted file mode 100644 index bad823c..0000000 --- a/src/features/swaps.js +++ /dev/null @@ -1,239 +0,0 @@ -// 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, installSwapWallet } from '../swap.js'; -import { getNetwork, getBoltzApi, boltzPresetsFor, 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; - installSwapWallet(wallet); // swap storage/keys live outside the core 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(), - }); - - 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(); } }, - boltzPresetsFor(getNetwork()).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, meta = null, skipArk = false) { - // The ark feature pays bolt11 straight from the ark balance when it can - // cover it (the ASP is the swap counterparty) — cheaper and instant, so - // it gets first refusal before the on-chain Boltz submarine path. - if (!skipArk && ctx.hook('startArkLnPay', invoice, meta)) return; - ui.sendError = ''; ui.lnSend = null; ui.lnSent = null; ui.lnPayMeta = meta; 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(); - const zap = ui.lnPayMeta; // set when a NIP-57 zap routed through here - 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' }, zap ? t('arkZapSentTitle') : t('lnPaySentTitle')), - zap && zap.name ? h('div', { class: 'small muted' }, zap.name) : null, - 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' }, zap ? t('lnZapSentBody') : t('lnPaySentBody'))), - h('button', { class: 'btn-primary btn-block', onClick: () => { ui.lnSent = null; ui.lnSend = null; ui.lnPayMeta = 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' }, zap ? '⚡ ' + t('lnZapReviewTitle') : t('lnPayTitle')), - zap && (zap.name || zap.address) ? h('div', { class: 'small muted', style: 'word-break:break-all' }, zap.name || zap.address) : null, - zap && zap.comment ? h('div', { class: 'small faint', style: 'font-style:italic;word-break:break-word' }, '“' + zap.comment + '”') : null, - 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.lnPayMeta = 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'), icon: '⚡', - 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()]; }, - // ---- cross-feature seam (the zaps feature pays a resolved bolt11) ---- - // True when Lightning payment is possible from this wallet/build — the - // zaps feature checks this before offering a Lightning zap. - canLnPay() { return !wallet.watchOnly; }, - // Pay a bolt11 that another feature resolved (e.g. a NIP-57 zap), reusing - // this feature's quote → review → confirm → success flow. `meta` decorates - // the review as a zap (recipient name, comment). Returns true so the hook - // dispatcher stops here. - startLnPay(invoice, meta) { startLnSend(invoice, meta || null); return true; }, - // The ark feature falls back here when the ark balance can't cover an - // invoice after fees — skipArk avoids bouncing back and forth. - startLnPayBoltz(invoice, meta) { startLnSend(invoice, meta || null, true); return true; }, - }; -} diff --git a/src/i18n.js b/src/i18n.js index 327bf01..4a05936 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -130,7 +130,7 @@ const STR = { hiwBasics4: 'You can even save this page and open it straight from your computer with no internet at all. It keeps working offline.', hiwBasics5: 'It follows the usual Bitcoin standards (a 12-word BIP39 phrase and BIP84 native SegWit keys), so if this site ever disappears you can restore the same phrase in Sparrow, Electrum, or any other standard wallet and your coins will be right there.', hiwLnTitle: 'Lightning', - hiwLn1: 'You can pay Lightning invoices and receive Lightning payments. Behind the scenes a swap provider exchanges between your on-chain coins and Lightning without ever holding your money — if a swap fails, the coins come back to you. A small swap fee applies, shown before you confirm.', + hiwLn1: 'You can pay Lightning invoices and receive Lightning payments. They run over your Ark balance: the Ark server exchanges with the Lightning network without ever holding your money — if a payment fails, the coins come back to you. A small fee applies, shown before you confirm.', hiwArkTitle: 'Ark', hiwArk1: 'Ark is a newer way to pay off-chain: sends arrive instantly, cost no mining fees, and stay self-custodial. You "board" coins from your on-chain balance into an Ark server, pay and receive freely, and move funds back on-chain whenever you like.', hiwArk2: 'The server can never take your money: every coin you hold in the Ark comes with a pre-signed escape route, and the wallet can put it back on-chain even if the server disappears (“Exit on-chain without the server” in Settings — it takes a while and needs a little on-chain balance for fees, but it needs nobody’s permission).', @@ -254,11 +254,6 @@ const STR = { giftConsolidateExplain: 'This gift would bundle {count} coins, making it costly for the recipient to claim. Splitting one coin first (~{fee}) gives them a clean single-input gift.', giftConsolidateFirst: 'Split into one coin first', giftUseManyCoins: 'Use {count} coins anyway', - consolidateTitle: 'Consolidate coins', - consolidateDesc: 'Your balance is spread across {n} coins. Merging them into one makes future sends and gifts cheaper and smaller.', - consolidateAction: 'Merge {n} coins into one', - consolidateDone: 'Consolidating — your coins are merging into one.', - consolidateFailed: 'Consolidation failed.', claimWithExtension: 'Claim with nostr extension', orEnterCode: 'or paste the claim code', extWrongAccount: 'Your extension is signed in as a different nostr account than this gift is for.', @@ -359,8 +354,6 @@ const STR = { detectedElectrum: 'Electrum — data + instant payment notifications.', detectedEsplora: 'Block explorer (Esplora) — data only; polls for payments.', silentPaymentNote: 'Silent payment', - spReceiveTitle: 'Silent payment address', - spReceiveDesc: "A reusable address that can't be tracked.", receiveAddressTab: 'Bitcoin address', receiveLnTab: 'Lightning', receiveSpTab: 'Silent payment', @@ -444,13 +437,6 @@ const STR = { nwcOff: 'Off', nwcBackgroundOn: 'Requests will be answered in the background', nwcBackgroundOff: 'Background answering turned off', - nwcPouch: 'Pouch (spendable while closed)', - nwcPouchDesc: 'Coins in the pouch can be spent by the background responder without opening Hal. Keep it small — top it up as it drains.', - nwcPouchFund: 'Add to pouch', - nwcPouchEmpty: 'Empty pouch', - nwcPouchFunded: 'Moved {sats} sats into the pouch', - nwcPouchEmptied: 'Moved {sats} sats back to the wallet', - nwcPouchAmount: 'Amount (sats)', nwcAppName: 'App name (e.g. Damus)', nwcMaxPayment: 'Max per payment (sats)', nwcDailyBudget: 'Daily budget (sats)', @@ -460,13 +446,6 @@ const STR = { nwcRevoke: 'Revoke', nwcRevoked: 'Revoked {name}', nwcLimits: 'Max {max}/payment · {day}/day · {spent} spent today', - arkBridgeTitle: 'Lightning swap bridge', - arkBridgeDesc: 'Optional third-party service that pays Lightning invoices from your Ark balance, usually cheaper than the Ark server itself. Trustless: your sats are locked in a hash contract the bridge can only take by actually paying your invoice.', - arkBridgeUrl: 'Bridge URL', - arkBridgeToken: 'Access token', - arkBridgeTokenPh: 'Leave blank if the bridge is open', - arkBridgeTrust: 'If the bridge fails to pay, your sats come straight back — it can never take them without settling your invoice.', - arkLnBridgeFee: 'Bridge fee', arkLnFundsPending: 'Your Ark funds are settling from a previous payment — retrying…', lnZapOffer: 'Zap {addr}', arkLnInvoiceIntro: 'Or receive over Lightning, straight into your Ark balance.', @@ -514,15 +493,6 @@ const STR = { lnZapHintPlain: 'Sent over Lightning (this address doesn’t support zap receipts).', lnZapSentBody: 'Your zap is on its way — the swap provider pays it once the on-chain lockup confirms.', lnZapFailed: 'Couldn’t complete the zap.', - spScan: 'Scan for silent payments', - spScanning: 'Scanning…', - spWatching: 'Watching for payments', - spRescan: 'Rescan', - spReceived: 'Found {n} silent payment output(s): {amount}', - spIndexerTitle: 'Silent payment indexer', - spIndexerDesc: 'Where to scan for received silent payments. It only serves public tweak data — your scan key stays on-device — but a public one can see which blocks hold your outputs, so self-host for full privacy.', - spIndexerUrl: 'Indexer URL', - spIndexerHint: 'A BIP-352 tweak indexer (e.g. your own).', deviceSync: 'Device sync', deviceSyncDesc: 'Keep this wallet in sync across your devices by storing an encrypted copy on a Nostr relay — only a device with your seed can read it. Turn it off to keep everything on this device.', syncAcross: 'Sync across devices', diff --git a/src/nwc-bg.js b/src/nwc-bg.js new file mode 100644 index 0000000..f539e45 --- /dev/null +++ b/src/nwc-bg.js @@ -0,0 +1,117 @@ +// Background NWC spending state — what the service worker answers (and pays) +// from while the app is closed. +// +// The record mirrors the wallet's REAL ark state: its vtxos (with the signed +// bytes), the chain-3 private keys for exactly those coins plus a window of +// upcoming indices for change, the NWC connections' service keys, and the +// worker's spend log. The seed itself never leaves localStorage. +// +// THREAT MODEL, stated plainly. Anything with same-origin script access, or +// a stolen unlocked device, can read IndexedDB and therefore spend the ark +// balance. That is the accepted cost of a wallet that answers while closed — +// the ark balance is treated as walking-around money, and the on-chain wallet +// (whose keys are never mirrored) stays out of reach. Per-connection NWC +// budgets are enforced by the worker before it pays. A user who does not +// enable background answering has no record here and loses nothing. + +const DB = 'hal-nwc-pouch'; // name kept from the pouch era so upgrades are seamless +const STORE = 'pouch'; +const VERSION = 1; + +function openDb() { + return new Promise((resolve, reject) => { + if (typeof indexedDB === 'undefined') return reject(new Error('IndexedDB unavailable')); + const req = indexedDB.open(DB, VERSION); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE); + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +async function tx(mode, fn) { + const db = await openDb(); + try { + return await new Promise((resolve, reject) => { + const t = db.transaction(STORE, mode); + const s = t.objectStore(STORE); + let out; + Promise.resolve(fn(s)).then((v) => { out = v; }).catch(reject); + t.oncomplete = () => resolve(out); + t.onerror = () => reject(t.error); + t.onabort = () => reject(t.error); + }); + } finally { db.close(); } +} +const get = (s, k) => new Promise((res, rej) => { const r = s.get(k); r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error); }); + +// One record per wallet, keyed by the wallet's cache key. +export async function loadBg(walletKey) { + try { return (await tx('readonly', (s) => get(s, walletKey))) || null; } catch { return null; } +} +export async function saveBg(walletKey, rec) { + return tx('readwrite', (s) => { s.put(rec, walletKey); }); +} +export async function clearBg(walletKey) { + return tx('readwrite', (s) => { s.delete(walletKey); }); +} +// The service worker doesn't know wallet cache keys — it finds the right +// record by the connection's service pubkey across all of them. +export async function allBgs() { + try { + return await tx('readonly', (s) => new Promise((res, rej) => { + const out = []; + const cur = s.openCursor(); + cur.onsuccess = () => { + const c = cur.result; + if (!c) return res(out); + out.push({ walletKey: c.key, rec: c.value }); + c.continue(); + }; + cur.onerror = () => rej(cur.error); + })); + } catch { return []; } +} + +// How many upcoming change/refund indices travel with the record: the worker +// cannot derive keys, so its allocations must land inside this window. +export const BG_KEY_LOOKAHEAD = 25; + +// { ark } endpoints, the manager state to mirror, and a keyFor(index) derive +// function (the caller owns the account node — it never enters this module). +export function buildBg({ ark, mgrState, keyFor, connections }) { + const keys = {}; + const idx = new Set((mgrState.vtxos || []).filter((v) => v.state !== 'spent').map((v) => v.keyIndex)); + for (let i = 0; i < BG_KEY_LOOKAHEAD; i++) idx.add((mgrState.nextKeyIndex || 1) + i); + idx.add(0); // the receive-address key: mailbox/refund defaults land on it + for (const i of idx) keys[String(i)] = keyFor(i); + return { + v: 3, + updated: Date.now(), + ark, + mgr: mgrState, + keys, + connections: (connections || []).map((c) => ({ + id: c.id, servicePk: c.servicePk, serviceSk: c.serviceSk, clientPk: c.clientPk, + maxSat: c.maxSat, dailySat: c.dailySat, + })), + }; +} + +export const bgSpendableSat = (rec) => + ((rec && rec.mgr && rec.mgr.vtxos) || []).filter((v) => v.state === 'spendable') + .reduce((n, v) => n + (v.amountSat || 0), 0); + +// The worker records what it spent so the app can absorb it into budgets and +// history on next open. +export async function noteBgSpend(walletKey, spend) { + const rec = await loadBg(walletKey); + if (!rec) return null; + rec.spends = rec.spends || []; + rec.spends.push({ ...spend, ts: Date.now() }); + if (rec.spends.length > 200) rec.spends = rec.spends.slice(-200); + await saveBg(walletKey, rec); + return rec; +} diff --git a/src/nwc-pouch.js b/src/nwc-pouch.js deleted file mode 100644 index 187ab7a..0000000 --- a/src/nwc-pouch.js +++ /dev/null @@ -1,148 +0,0 @@ -// The NWC "pouch": a small, bounded spending capability that a service -// worker can use while the wallet itself stays out of reach. -// -// WHY THIS EXISTS -// hal keeps the seed in localStorage, which service workers cannot read — by -// design, and we are not changing that. But answering a wallet-connect -// request while the app is closed means signing in a service worker. So -// instead of exposing the wallet, we carve off a deliberately small pouch: -// -// * its own key branch (account chain 6), never used for ordinary coins -// * only the pouch's PRIVATE KEYS and vtxo bytes go into IndexedDB, which -// the service worker can read — never the seed, never chain 3 -// * it holds only what the user puts in it -// -// THREAT MODEL, stated plainly. Anything with same-origin script access, or -// a stolen unlocked device, can read IndexedDB and therefore spend the pouch. -// That is the accepted cost of answering while closed. It is bounded: the -// pouch is the blast radius, the rest of the wallet is not reachable from it. -// A user who does not enable background NWC has no pouch and loses nothing. -// -// This module owns the storage and key derivation only. Funding it is an -// ordinary arkoor send from the main wallet (see the ark feature); spending -// from it lives in the service worker (step 3). - -import { hex } from '@scure/base'; -import { secp256k1 } from '@noble/curves/secp256k1'; - -const DB = 'hal-nwc-pouch'; -const STORE = 'pouch'; -const VERSION = 1; - -// Chain 6 is the pouch. Chains 0-2 are hal's on-chain use, 3 ark vtxo keys, -// 4 the ark mailbox, 5 lightning-receive preimages — 6 must stay distinct so -// a leaked pouch key can never sign for an ordinary coin. -export const POUCH_CHAIN = 6; - -export function pouchKey(account, index) { - const node = account.deriveChild(POUCH_CHAIN).deriveChild(index); - return { privkey: node.privateKey, pubkey: secp256k1.getPublicKey(node.privateKey, true) }; -} - -function openDb() { - return new Promise((resolve, reject) => { - if (typeof indexedDB === 'undefined') return reject(new Error('IndexedDB unavailable')); - const req = indexedDB.open(DB, VERSION); - req.onupgradeneeded = () => { - const db = req.result; - if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE); - }; - req.onsuccess = () => resolve(req.result); - req.onerror = () => reject(req.error); - }); -} - -async function tx(mode, fn) { - const db = await openDb(); - try { - return await new Promise((resolve, reject) => { - const t = db.transaction(STORE, mode); - const s = t.objectStore(STORE); - let out; - Promise.resolve(fn(s)).then((v) => { out = v; }).catch(reject); - t.oncomplete = () => resolve(out); - t.onerror = () => reject(t.error); - t.onabort = () => reject(t.error); - }); - } finally { db.close(); } -} -const get = (s, k) => new Promise((res, rej) => { const r = s.get(k); r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error); }); - -// One record per wallet, keyed by the wallet's cache key so several wallets -// on one device keep separate pouches. -export async function loadPouch(walletKey) { - try { return (await tx('readonly', (s) => get(s, walletKey))) || null; } catch { return null; } -} -// The service worker doesn't know wallet cache keys — it finds the right -// pouch by the connection's service pubkey across all of them. -export async function allPouches() { - try { - return await tx('readonly', (s) => new Promise((res, rej) => { - const out = []; - const cur = s.openCursor(); - cur.onsuccess = () => { - const c = cur.result; - if (!c) return res(out); - out.push({ walletKey: c.key, pouch: c.value }); - c.continue(); - }; - cur.onerror = () => rej(cur.error); - })); - } catch { return []; } -} -export async function savePouch(walletKey, pouch) { - return tx('readwrite', (s) => { s.put(pouch, walletKey); }); -} -export async function clearPouch(walletKey) { - return tx('readwrite', (s) => { s.delete(walletKey); }); -} - -// The worker can't derive keys (no seed), so the pouch carries a fixed window -// of pre-derived chain-6 keys: funding uses low indices, the worker's change -// and HTLC-refund outputs allocate upward from POUCH_CHANGE_BASE. -export const POUCH_KEY_WINDOW = 50; -export const POUCH_CHANGE_BASE = 20; - -// Everything the service worker needs to spend, and nothing more: the ASP -// endpoints, the bridge, the pouch vtxos with their bytes, and the private -// keys for exactly the pouch's key window. -export function buildPouch({ arkUrl, esploraUrl, network, serverPubkey, bridge, vtxos, account, connections }) { - const keys = {}; - for (let i = 0; i < POUCH_KEY_WINDOW; i++) { - keys[String(i)] = hex.encode(pouchKey(account, i).privkey); - } - return { - v: 2, - updated: Date.now(), - ark: { arkUrl, esploraUrl, network, serverPubkey }, - bridge: bridge || null, // { url, token } — the worker pays through this - // [{ id, bytes, keyIndex, amountSat, expiryHeight }] - vtxos: vtxos.map((v) => ({ - id: v.id, bytes: v.bytes, keyIndex: v.keyIndex, - amountSat: v.amountSat, expiryHeight: v.expiryHeight, - })), - keys, - nextKeyIndex: POUCH_CHANGE_BASE, - // service keys for the connections the SW may answer for, so it can - // decrypt requests and sign replies without the wallet - connections: (connections || []).map((c) => ({ - id: c.id, servicePk: c.servicePk, serviceSk: c.serviceSk, clientPk: c.clientPk, - maxSat: c.maxSat, dailySat: c.dailySat, - })), - }; -} - -export const pouchBalance = (pouch) => - (pouch?.vtxos || []).reduce((n, v) => n + (v.amountSat || 0), 0); - -// The service worker records what it spent so the app can reconcile and the -// daily budget survives a restart. -export async function notePouchSpend(walletKey, { connId, amountSat, paymentHash, preimage }) { - const p = await loadPouch(walletKey); - if (!p) return null; - p.spends = p.spends || []; - p.spends.push({ connId, amountSat, paymentHash, preimage, ts: Date.now() }); - if (p.spends.length > 200) p.spends = p.spends.slice(-200); - await savePouch(walletKey, p); - return p; -} diff --git a/src/nwc-respond.js b/src/nwc-respond.js index e7bb716..c0897d1 100644 --- a/src/nwc-respond.js +++ b/src/nwc-respond.js @@ -1,28 +1,28 @@ -// NWC auto-answer from the pouch — the half of background wallet-connect -// that pays without a tap. Runs inside the service worker's push handler: -// the push payload carries the encrypted request event, the pouch (IndexedDB) -// carries the connection service keys and a bounded set of coins, and the -// reply goes back through the notifier's /publish endpoint — no WebSocket, -// no seed, nothing outside the pouch's blast radius. +// NWC auto-answer while the app is closed. Runs inside the service worker's +// push handler: the push payload carries the encrypted request event, the +// background record (IndexedDB) carries the connection service keys and a +// mirror of the wallet's ark state, and the reply goes back through the +// notifier's /publish endpoint — no WebSocket, no seed. // // Trust: the notifier transports ciphertext both ways. Requests arrive // nip04/nip44-encrypted and signed by the client; replies leave encrypted and // signed by the per-connection service key. The notifier can neither read nor -// forge either. Spending happens directly against the ASP/bridge over fetch. +// forge either. Payments run the ASP's own Lightning send directly over +// fetch — the same path the open app uses. // // Anything this module can't handle returns false, and the caller falls back -// to today's behavior: a notification that opens the wallet. +// to a notification that opens the wallet. import { hex } from '@scure/base'; -import { sha256 } from '@noble/hashes/sha256'; import * as nip04 from 'nostr-tools/nip04'; import * as nip44 from 'nostr-tools/nip44'; import { finalizeEvent } from 'nostr-tools/pure'; import { ArkManager } from './ark/manager.js'; import { maybeBolt11 } from './ark/lightning.js'; -import { allPouches, savePouch, POUCH_KEY_WINDOW, POUCH_CHANGE_BASE } from './nwc-pouch.js'; +import { allBgs, saveBg } from './nwc-bg.js'; const MAX_AGE_SEC = 120; +const PAY_WAIT_MS = 20_000; // inside the SW's ~30s budget, with reply margin const nowSec = () => Math.floor(Date.now() / 1000); const errRes = (t, code, message) => ({ result_type: t, error: { code, message } }); @@ -42,24 +42,15 @@ async function buildReply(conn, ev, scheme, payload) { }, sk); } -const seedState = (pouch) => ({ - v: 1, - serverPubkey: pouch.ark.serverPubkey || null, - mailboxCheckpoint: 0, - nextKeyIndex: pouch.nextKeyIndex || POUCH_CHANGE_BASE, - vtxos: (pouch.vtxos || []).map((v) => ({ ...v, state: v.state || 'spendable' })), - actions: [], - movements: [], -}); - -const spendableVtxos = (pouch) => - ((pouch.mgr && pouch.mgr.vtxos) || seedState(pouch).vtxos).filter((v) => v.state === 'spendable'); +const spendableSat = (rec) => + ((rec.mgr && rec.mgr.vtxos) || []).filter((v) => v.state === 'spendable') + .reduce((n, v) => n + (v.amountSat || 0), 0); // data: the push payload { type:'nwc', servicePubkey, event }. Returns true // when the request was fully dealt with (reply published or deliberately // left to another device); false means "wake the user instead". -export async function respondFromPouch(data, { - notifier, fetchFn = fetch, pouchesFn = allPouches, saveFn = savePouch, log = () => {}, +export async function respondFromBg(data, { + notifier, fetchFn = fetch, recordsFn = allBgs, saveFn = saveBg, log = () => {}, } = {}) { const ev = data && data.event; if (!ev || ev.kind !== 23194 || !ev.id || !ev.pubkey) return false; @@ -67,10 +58,10 @@ export async function respondFromPouch(data, { const target = (ev.tags?.find((t) => t[0] === 'p') || [])[1]; if (!target) return false; - let walletKey = null, pouch = null, conn = null; - for (const p of await pouchesFn()) { - const c = (p.pouch.connections || []).find((x) => x.servicePk === target); - if (c) { walletKey = p.walletKey; pouch = p.pouch; conn = c; break; } + let walletKey = null, rec = null, conn = null; + for (const r of await recordsFn()) { + const c = (r.rec.connections || []).find((x) => x.servicePk === target); + if (c && r.rec.v >= 3) { walletKey = r.walletKey; rec = r.rec; conn = c; break; } } if (!conn) return false; if (ev.pubkey !== conn.clientPk) return false; @@ -92,7 +83,7 @@ export async function respondFromPouch(data, { }; // Another device (an open tab elsewhere) may have answered already — a // paid zap must never be shouted over with a late error, and paying twice - // wastes a lock cycle. The notifier tracks recent reply e-tags. + // wastes funds-in-flight. The notifier tracks recent reply e-tags. const answered = async () => { try { const r = await fetchFn(`${notifier}/answered?event=${ev.id}`); @@ -105,7 +96,7 @@ export async function respondFromPouch(data, { result_type: 'get_info', result: { alias: 'Hal', color: '#f7931a', - network: pouch.ark.network === 'mainnet' ? 'mainnet' : pouch.ark.network, + network: rec.ark.network === 'mainnet' ? 'mainnet' : rec.ark.network, block_height: 0, block_hash: '', methods: ['get_info', 'get_balance', 'pay_invoice', 'list_transactions'], }, @@ -113,12 +104,11 @@ export async function respondFromPouch(data, { return true; } if (method === 'get_balance') { - const sat = spendableVtxos(pouch).reduce((n, v) => n + (v.amountSat || 0), 0); - await publish({ result_type: 'get_balance', result: { balance: sat * 1000 } }); + await publish({ result_type: 'get_balance', result: { balance: spendableSat(rec) * 1000 } }); return true; } if (method === 'list_transactions') { - const txs = (pouch.spends || []).slice(-(params.limit || 20)).map((s) => ({ + const txs = (rec.spends || []).slice(-(params.limit || 20)).map((s) => ({ type: 'outgoing', invoice: s.invoice || '', preimage: s.preimage || '', amount: (s.amountSat || 0) * 1000, fees_paid: (s.feeSat || 0) * 1000, created_at: Math.floor((s.ts || Date.now()) / 1000), @@ -145,101 +135,76 @@ export async function respondFromPouch(data, { return true; } const today = new Date().toISOString().slice(0, 10); - const spentToday = (pouch.spends || []) + const spentToday = (rec.spends || []) .filter((s) => s.connId === conn.id && new Date(s.ts).toISOString().slice(0, 10) === today) .reduce((n, s) => n + (s.amountSat || 0) + (s.feeSat || 0), 0); if (spentToday + dec.amountSat > conn.dailySat) { await publish(errRes('pay_invoice', 'QUOTA_EXCEEDED', `over the remaining daily budget (${Math.max(0, conn.dailySat - spentToday)} sat)`)); return true; } + if (spendableSat(rec) < dec.amountSat) return false; // stale/empty mirror: wake the user - // The worker only pays through the bridge (one HTLC lock, one HTTP swap). - // No bridge, no coins, or an exhausted key window → wake the user instead. - if (!pouch.bridge || !pouch.bridge.url) return false; - const nextIdx = (pouch.mgr && pouch.mgr.nextKeyIndex) || pouch.nextKeyIndex || POUCH_CHANGE_BASE; - if (nextIdx >= POUCH_KEY_WINDOW - 1) { log('pouch key window exhausted'); return false; } - - const auth = pouch.bridge.token ? { authorization: `Bearer ${pouch.bridge.token}` } : {}; - let quote; - try { - const r = await fetchFn(`${pouch.bridge.url}/quote?invoice=${encodeURIComponent(invoice)}`, { headers: auth }); - quote = await r.json(); - if (!r.ok || quote.error) throw new Error(quote.error || `quote failed (${r.status})`); - } catch (e) { log('quote failed: ' + e.message); return false; } - if (!spendableVtxos(pouch).some((v) => v.amountSat >= quote.totalSat)) { - log('pouch cannot cover ' + quote.totalSat); - return false; // underfunded pouch: the user should top up — notify - } - + // The ASP's own Lightning send, over the mirrored state. Change and HTLC + // outputs allocate from nextKeyIndex, which must stay inside the key + // window the app pre-derived; if it ran out, wake the user instead. + if (!rec.keys[String(rec.mgr.nextKeyIndex || 1)]) { log('key window exhausted'); return false; } const storage = { - load: () => pouch.mgr || seedState(pouch), - save: (s) => { pouch.mgr = s; saveFn(walletKey, pouch).catch(() => {}); }, + load: () => rec.mgr, + save: (s) => { rec.mgr = s; saveFn(walletKey, rec).catch(() => {}); }, }; - const keyShim = { deriveChild: () => ({ deriveChild: (i) => ({ privateKey: hex.decode(pouch.keys[String(i)] || pouch.keys['0']) }) }) }; + const keyShim = { deriveChild: () => ({ deriveChild: (i) => { + const k = rec.keys[String(i)]; + if (!k) throw new Error(`key index ${i} outside the mirrored window`); + return { privateKey: hex.decode(k) }; + } }) }; let mgr; try { mgr = await new ArkManager({ account: keyShim, storage, - arkUrl: pouch.ark.arkUrl, esploraUrl: pouch.ark.esploraUrl, network: pouch.ark.network, + arkUrl: rec.ark.arkUrl, esploraUrl: rec.ark.esploraUrl, network: rec.ark.network, }).init(); } catch (e) { log('ark init failed: ' + e.message); return false; } - let lock; - try { - lock = await mgr.htlcLock({ - amountSat: quote.totalSat, claimPubkey: quote.claimPubkey, - paymentHash: quote.paymentHash, htlcExpiry: quote.htlcExpiry, - }); - } catch (e) { log('htlc lock failed: ' + e.message); return false; } - - // Persist the swap BEFORE handing it to the bridge: if we die mid-flight, - // the app finds the record on next open and runs its refund machinery. - pouch.swaps = pouch.swaps || []; - const rec = { - paymentHash: quote.paymentHash, invoice, bridgeUrl: pouch.bridge.url, - amountSat: quote.amountSat, feeSat: quote.feeSat, - htlcVtxo: lock.htlcVtxos[0], refundIndex: lock.refundIndex, - htlcExpiry: quote.htlcExpiry, step: 'submitted', ts: Date.now(), - }; - pouch.swaps.push(rec); - await saveFn(walletKey, pouch); - - let res; + let id; try { - const r = await fetchFn(`${pouch.bridge.url}/swap`, { - method: 'POST', headers: { 'content-type': 'application/json', ...auth }, - body: JSON.stringify({ invoice, htlcVtxo: lock.htlcVtxos[0] }), - }); - res = await r.json(); - if (!r.ok || res.error) throw new Error(res.error || `bridge error (${r.status})`); - if (res.step !== 'done') throw new Error(res.error || 'bridge did not pay'); - if (!res.preimage || hex.encode(sha256(hex.decode(res.preimage))) !== quote.paymentHash) { - throw new Error('bridge returned an invalid preimage'); - } + id = await mgr.payLnInvoice(invoice); } catch (e) { - rec.step = 'refundable'; - rec.error = e.message; - await saveFn(walletKey, pouch); - // the winner may have paid while we were locking — never shout over it if (!(await answered())) { await publish(errRes('pay_invoice', 'INTERNAL', e.message)).catch(() => {}); } return true; } + const deadline = Date.now() + PAY_WAIT_MS; + let a = mgr.lnAction(id); + while (a && !['done', 'failed'].includes(a.step) && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 1500)); + await mgr.driveLn(id).catch(() => {}); + a = mgr.lnAction(id); + } + if (!a || a.step === 'failed') { + if (!(await answered())) { + await publish(errRes('pay_invoice', 'INTERNAL', (a && a.error) || 'payment failed')).catch(() => {}); + } + return true; + } + if (a.step !== 'done') { + // still in flight as our budget runs out: the app resumes it from the + // mirrored state on next open — wake the user rather than guess. + log('payment still pending at SW deadline'); + return false; + } - rec.step = 'done'; - rec.preimage = res.preimage; - pouch.spends = pouch.spends || []; - pouch.spends.push({ - connId: conn.id, amountSat: quote.amountSat, feeSat: quote.feeSat, - paymentHash: quote.paymentHash, preimage: res.preimage, invoice, ts: Date.now(), + rec.spends = rec.spends || []; + rec.spends.push({ + connId: conn.id, amountSat: a.amountSat || dec.amountSat, feeSat: a.feeSat || 0, + paymentHash: dec.paymentHash, preimage: a.preimage, invoice, ts: Date.now(), }); - if (pouch.spends.length > 200) pouch.spends = pouch.spends.slice(-200); - await saveFn(walletKey, pouch); + if (rec.spends.length > 200) rec.spends = rec.spends.slice(-200); + await saveFn(walletKey, rec); await publish({ result_type: 'pay_invoice', - result: { preimage: res.preimage, fees_paid: (quote.feeSat || 0) * 1000 }, + result: { preimage: a.preimage, fees_paid: (a.feeSat || 0) * 1000 }, }); - log(`paid ${quote.amountSat} sat via bridge from the pouch`); + log(`paid ${a.amountSat || dec.amountSat} sat via the ASP from the background mirror`); return true; } diff --git a/src/sp-worker.js b/src/sp-worker.js deleted file mode 100644 index 59a1769..0000000 --- a/src/sp-worker.js +++ /dev/null @@ -1,69 +0,0 @@ -// Web Worker: the EC-heavy silent-payment crypto, kept off the main thread so a -// large scan never janks the UI. Memoizes per tweak (a tweak's outcome is fixed), -// so repeated mempool pushes — which re-send the same tweaks — stay cheap. -// -// Protocol (request/response by id): -// { id, op:'keys', scanPriv, spendPub } → { id, ok:true } -// { id, op:'candidates', blocks:[{height,tweaks,filter}] } → { id, hits:[height] } -// { id, op:'scan', items:[{txid,tweak,outputs}] } → { id, found:[utxo] } - -import { hex } from '@scure/base'; -import { silentPaymentScan, silentPaymentCandidate, bloomHas } from './silentpay.js'; - -let scanPriv = null; -let spendPub = null; -const scanCache = new Map(); // tweakHex -> matches -const candCache = new Map(); // tweakHex -> candidate x-only hex - -self.onmessage = (e) => { - const { id, op } = e.data; - try { - if (op === 'keys') { - scanPriv = e.data.scanPriv; - spendPub = e.data.spendPub; - scanCache.clear(); - candCache.clear(); - self.postMessage({ id, ok: true }); - return; - } - if (op === 'candidates') { - // Return the heights of blocks whose Bloom filter could contain one of our - // outputs (derive a candidate per tweak, test the filter, stop on first hit). - const hits = []; - for (const b of e.data.blocks) { - for (const tw of b.tweaks) { - let c = candCache.get(tw); - if (!c) { - if (candCache.size > 100000) candCache.clear(); - c = silentPaymentCandidate({ scanPriv, spendPub, tweak: hex.decode(tw) }); - candCache.set(tw, c); - } - if (bloomHas(b.filter, c)) { hits.push(b.height); break; } - } - } - self.postMessage({ id, hits }); - return; - } - if (op === 'scan') { - // Full scan: return resolved utxos {txid,vout,value,xonly,tweak} for ours. - const found = []; - for (const it of e.data.items) { - let m = scanCache.get(it.tweak); - if (!m) { - if (scanCache.size > 100000) scanCache.clear(); - m = silentPaymentScan({ scanPriv, spendPub, tweak: hex.decode(it.tweak), outputs: it.outputs.map((o) => o.xonly) }); - scanCache.set(it.tweak, m); - } - for (const mm of m) { - const o = it.outputs.find((x) => x.xonly === mm.output); - if (o) found.push({ txid: it.txid, vout: o.vout, value: o.value, xonly: mm.output, tweak: mm.tweak }); - } - } - self.postMessage({ id, found }); - return; - } - self.postMessage({ id, error: 'unknown op ' + op }); - } catch (err) { - self.postMessage({ id, error: String((err && err.message) || err) }); - } -}; diff --git a/src/sw-nwc.js b/src/sw-nwc.js index db57b99..8267b6b 100644 --- a/src/sw-nwc.js +++ b/src/sw-nwc.js @@ -1,9 +1,10 @@ // Service-worker side of background NWC. Bundled by build.js and appended to // the generated sw.js, replacing the notify-only push handler: a push now -// first tries to ANSWER the request from the pouch (see nwc-respond.js), and -// only falls back to the wake-the-user notification when it can't. +// first tries to ANSWER the request from the background state mirror (see +// nwc-respond.js), and only falls back to the wake-the-user notification +// when it can't. -import { respondFromPouch } from './nwc-respond.js'; +import { respondFromBg } from './nwc-respond.js'; const NOTIFIER = 'https://nwcpush.coinos.io'; @@ -21,7 +22,7 @@ self.addEventListener('push', (e) => { } let handled = false; try { - handled = await respondFromPouch(data, { + handled = await respondFromBg(data, { notifier: NOTIFIER, log: (m) => console.log('[sw-nwc]', m), }); diff --git a/src/swap.js b/src/swap.js deleted file mode 100644 index 7aec8cd..0000000 --- a/src/swap.js +++ /dev/null @@ -1,459 +0,0 @@ -// Boltz submarine + reverse swap support for Hal — fully client-side and -// non-custodial. Hal holds the swap key + preimage and can always claim -// (reverse) or refund (submarine) on-chain via the Taproot SCRIPT PATH, so an -// uncooperative/offline Boltz can never strand funds. The cooperative MuSig2 -// key-path spend (smaller witness) is a future optimization; correctness here -// relies only on the script path. -// -// Tapscript byte layouts and the two-leaf tree are replicated exactly from -// boltz-core (src/bitcoin/scripts/{swap_tree,reverse_tree,tree}.rs) and unit- -// tested against its published test vectors (see tools/swap-vectors.test.js). - -import * as btc from '@scure/btc-signer'; -import { p2tr, taprootListToTree, tapLeafHash as scureTapLeafHash } from '@scure/btc-signer/payment'; -import { keyAggregate, keyAggExport, sortKeys } from '@scure/btc-signer/musig2'; -import { schnorr } from '@noble/curves/secp256k1'; -import { sha256 } from '@noble/hashes/sha2'; -import { ripemd160 } from '@noble/hashes/legacy'; -import { hexToBytes, bytesToHex, concatBytes } from '@noble/hashes/utils'; -import { bech32 } from '@scure/base'; - -export { hexToBytes, bytesToHex }; - -// Regtest network object (same version bytes as testnet, `bcrt` HRP). -export const REGTEST = { ...btc.TEST_NETWORK, bech32: 'bcrt' }; -// mutinynet is a signet — same address params as testnet (tb1…), matching Boltz's -// bitcoinSignet. Without this entry netFor() falls back to mainnet and the swap -// P2TR address is computed wrong → "lockup address mismatch". -const NETS = { mainnet: btc.NETWORK, testnet: btc.TEST_NETWORK, signet: btc.TEST_NETWORK, mutinynet: btc.TEST_NETWORK, regtest: REGTEST }; -export const netFor = (name) => NETS[name] || btc.NETWORK; - -const TAP_LEAF_VERSION = 0xc0; - -export const hash160 = (b) => ripemd160(sha256(b)); -const sha256d = (b) => sha256(b); - -// BIP340/341 tagged hash: sha256(sha256(tag) || sha256(tag) || msg). -function taggedHash(tag, ...msgs) { - const tagHash = sha256(new TextEncoder().encode(tag)); - return sha256(concatBytes(tagHash, tagHash, ...msgs)); -} - -// Minimal CScriptNum push of a non-negative locktime, as bitcoin's -// script::Builder::push_lock_time / push_int produces (used in the refund leaf). -function pushScriptNum(n) { - if (n === 0) return Uint8Array.of(0x00); // OP_0 - if (n >= 1 && n <= 16) return Uint8Array.of(0x50 + n); // OP_1..OP_16 - const out = []; - let v = n; - while (v > 0) { out.push(v & 0xff); v >>= 8; } - if (out[out.length - 1] & 0x80) out.push(0x00); // sign byte for positives - return concatBytes(Uint8Array.of(out.length), Uint8Array.from(out)); -} - -// data push of a byte slice with its OP_PUSHBYTES_N length prefix (slices here -// are always < 76 bytes: a 20-byte hash160 or a 32-byte x-only key). -const pushData = (b) => concatBytes(Uint8Array.of(b.length), b); - -const OP = { HASH160: 0xa9, EQUALVERIFY: 0x88, CHECKSIG: 0xac, CHECKSIGVERIFY: 0xad, CLTV: 0xb1, SIZE: 0x82 }; - -// Submarine claim leaf: OP_HASH160 <h160> OP_EQUALVERIFY <claimX> OP_CHECKSIG -export function submarineClaimLeaf(preimageHash160, claimXonly) { - return concatBytes( - Uint8Array.of(OP.HASH160), pushData(preimageHash160), Uint8Array.of(OP.EQUALVERIFY), - pushData(claimXonly), Uint8Array.of(OP.CHECKSIG), - ); -} - -// Reverse claim leaf: OP_SIZE <32> OP_EQUALVERIFY OP_HASH160 <h160> OP_EQUALVERIFY <claimX> OP_CHECKSIG -export function reverseClaimLeaf(preimageHash160, claimXonly) { - return concatBytes( - Uint8Array.of(OP.SIZE), pushData(Uint8Array.of(32)), Uint8Array.of(OP.EQUALVERIFY), - Uint8Array.of(OP.HASH160), pushData(preimageHash160), Uint8Array.of(OP.EQUALVERIFY), - pushData(claimXonly), Uint8Array.of(OP.CHECKSIG), - ); -} - -// Refund leaf (both kinds): <refundX> OP_CHECKSIGVERIFY <locktime> OP_CLTV -export function refundLeaf(refundXonly, lockTime) { - return concatBytes( - pushData(refundXonly), Uint8Array.of(OP.CHECKSIGVERIFY), - pushScriptNum(lockTime), Uint8Array.of(OP.CLTV), - ); -} - -// Use @scure's BIP341 leaf hash (handles the compact-size encoding + tag). -export function tapLeafHash(script) { - return scureTapLeafHash(script, TAP_LEAF_VERSION); -} - -// Two-leaf Taproot merkle root: TapBranch of the lexicographically-sorted -// (claim, refund) leaf hashes (BIP341). -export function swapMerkleRoot(claimLeafScript, refundLeafScript) { - const a = tapLeafHash(claimLeafScript); - const b = tapLeafHash(refundLeafScript); - const [lo, hi] = compareBytes(a, b) <= 0 ? [a, b] : [b, a]; - return taggedHash('TapBranch', lo, hi); -} - -function compareBytes(a, b) { - for (let i = 0; i < a.length; i++) { if (a[i] !== b[i]) return a[i] - b[i]; } - return 0; -} - -// Build the full swap tree for a given kind from Hal's own inputs. Returns the -// leaf scripts + merkle root so the caller can verify Boltz's lockup address -// and later construct a script-path spend. -export function buildSwapTree(kind, preimageHash160, claimXonly, refundXonly, lockTime) { - const claim = kind === 'reverse' - ? reverseClaimLeaf(preimageHash160, claimXonly) - : submarineClaimLeaf(preimageHash160, claimXonly); - const refund = refundLeaf(refundXonly, lockTime); - return { claimLeaf: claim, refundLeaf: refund, merkleRoot: swapMerkleRoot(claim, refund) }; -} - -// Taproot internal key = MuSig2 aggregate of the two 33-byte pubkeys, x-only, -// in the FIXED order [boltzKey, halKey] (no sort). Boltz builds the musig as -// Musig.create(ourPriv, [ourPub, theirPub]) without sorting, and "ours" is -// always Boltz, so the address is agg([boltzPub, halPub]). Verified live -// against Boltz's lockupAddress (6/6; sorted fails when key order differs). -export function swapInternalKey(boltzPub33, halPub33) { - return keyAggExport(keyAggregate([boltzPub33, halPub33])); -} - -// Full swap P2TR for either flow. Roles by kind: -// reverse — hal CLAIMS (preimage), Boltz REFUNDS → claim leaf = halKey, refund leaf = boltzKey -// submarine — Boltz CLAIMS (preimage), hal REFUNDS → claim leaf = boltzKey, refund leaf = halKey -// Internal key is always agg([boltzKey, halKey]). Pubkeys are 33-byte compressed. -// Returns the address + @scure p2tr payment (for control blocks/spending) + leaves. -export function swapP2TR({ kind, preimageHash160, boltzPub33, halPub33, lockTime, network = 'regtest' }) { - const claimPub = kind === 'reverse' ? halPub33 : boltzPub33; // who can claim - const refundPub = kind === 'reverse' ? boltzPub33 : halPub33; // who can refund - const claim = kind === 'reverse' - ? reverseClaimLeaf(preimageHash160, claimPub.slice(1)) - : submarineClaimLeaf(preimageHash160, claimPub.slice(1)); - const refund = refundLeaf(refundPub.slice(1), lockTime); - const internalKey = swapInternalKey(boltzPub33, halPub33); - const tree = taprootListToTree([{ script: claim }, { script: refund }]); - const pay = p2tr(internalKey, tree, netFor(network), true); - return { address: pay.address, script: pay.script, pay, internalKey, claimLeaf: claim, refundLeaf: refund }; -} - -const eqBytes = (a, b) => a.length === b.length && a.every((x, i) => x === b[i]); - -// Build a signed taproot SCRIPT-PATH spend of a swap lockup output. This is the -// non-custodial path that needs no Boltz cooperation: -// reverse claim — witness [sig, preimage, claimLeaf, controlBlock] -// submarine refund — witness [sig, refundLeaf, controlBlock], nLockTime=lockTime -// `leaf` is the leaf script being spent; `preimage` is required for a claim and -// omitted for a refund. Returns the raw signed tx hex ready to broadcast. -export function buildSwapSpend({ - pay, leaf, halPriv, preimage = null, - lockupTxid, lockupVout, lockupValue, destAddress, - fee, lockTime = 0, network = 'regtest', -}) { - const net = netFor(network); - const isRefund = preimage === null; - // Find the @scure leaf entry (control block + script||version) for this leaf. - const idx = pay.leaves.findIndex((l) => eqBytes(l.script, leaf)); - if (idx < 0) throw new Error('leaf not found in swap tree'); - const controlBlock = pay.leaves[idx].controlBlock; - - const tx = new btc.Transaction({ allowUnknownOutputs: true, version: 2 }); - tx.addInput({ - txid: lockupTxid, index: lockupVout, - witnessUtxo: { script: pay.script, amount: BigInt(lockupValue) }, - tapLeafScript: [pay.tapLeafScript[idx]], - tapInternalKey: pay.tapInternalKey, - sequence: isRefund ? 0xfffffffd : 0xffffffff, // refund: non-final for CLTV - }); - if (isRefund && lockTime) tx.lockTime = lockTime; - tx.addOutputAddress(destAddress, BigInt(lockupValue - fee), net); - - // Sighash for the leaf (BIP341 script path, SIGHASH_DEFAULT) + schnorr sig. - const sighash = tx.preimageWitnessV1( - 0, [pay.script], btc.SigHash.DEFAULT, [BigInt(lockupValue)], - undefined, leaf, TAP_LEAF_VERSION, - ); - const sig = schnorr.sign(sighash, halPriv); - - // Assemble the witness manually (preimage inserted for a claim). - const witness = isRefund - ? [sig, leaf, controlBlock] - : [sig, preimage, leaf, controlBlock]; - tx.updateInput(0, { finalScriptWitness: witness }); - return tx.hex; -} - -// --------------------------------------------------------------------------- -// Boltz api-v2 client + bolt11 decode + SwapManager (polling-based). -// --------------------------------------------------------------------------- - -async function boltzReq(apiBase, path, body) { - const opt = body - ? { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) } - : {}; - const r = await fetch(apiBase.replace(/\/+$/, '') + path, opt); - const j = await r.json().catch(() => ({})); - if (!r.ok) throw new Error(j?.error || `boltz ${path}: ${r.status}`); - return j; -} - -function convertBits(data, from, to) { - let acc = 0, bits = 0; const out = []; const maxv = (1 << to) - 1; - for (const v of data) { acc = (acc << from) | v; bits += from; while (bits >= to) { bits -= to; out.push((acc >> bits) & maxv); } } - return Uint8Array.from(out); -} - -// Decode the payment hash (+ amount in msat, best-effort) from a bolt11 invoice. -export function decodeBolt11(invoice) { - const dec = bech32.decode(invoice.toLowerCase(), 4000); - const words = dec.words; - const m = dec.prefix.match(/^ln[a-z]+?(\d+)([munp])?$/); - let amountMsat = null; - if (m && m[1]) { - const n = BigInt(m[1]); - if (m[2] === 'p') amountMsat = n / 10n; - else amountMsat = n * ({ m: 100000000n, u: 100000n, n: 100n }[m[2]] ?? 100000000000n); - } - let payHash = null; - const end = words.length - 104; // last 104 words = signature - for (let i = 7; i + 3 <= end;) { - const type = words[i]; - const len = (words[i + 1] << 5) | words[i + 2]; - if (type === 1 && len === 52) payHash = convertBits(words.slice(i + 3, i + 3 + len), 5, 8).slice(0, 32); - i += 3 + len; - } - if (!payHash) throw new Error('invoice missing payment hash'); - return { paymentHash: payHash, amountMsat }; -} - -// Orchestrates non-custodial swaps: creates them via Boltz, verifies the lockup -// address locally before any funds move, then polls the chain (via the wallet's -// esplora api) to claim (reverse) or detect success/refund (submarine). DOM-free. -// Wallet storage/key helpers for this feature, installed onto the core -// wallet instance so a build without the feature ships none of it. -export function installSwapWallet(wallet) { - if (wallet.loadSwaps) return; // already installed - Object.assign(wallet, { - // ---- Boltz swap support ---------------------------------------------- - // Persisted swap records (public metadata only — keys/preimage are re-derived - // deterministically from the seed via swapNode, so nothing secret is stored). - _swapKey() { return this._cacheKey() + ':swaps'; }, - loadSwaps() { - try { return JSON.parse(localStorage.getItem(this._swapKey()) || '[]'); } catch { return []; } - }, - saveSwaps(list) { - try { localStorage.setItem(this._swapKey(), JSON.stringify(list)); } catch {} - }, - _swapIdxKey() { return this._cacheKey() + ':swapidx'; }, - // Reserve the next swap index: monotonic + persisted on every call. A reverse/ - // submarine attempt that fails AFTER Boltz created the swap (a later validation - // throw, a dropped response) still burns its index, so a retry can't reuse the - // same deterministic preimage (Boltz rejects "swap with this preimage exists"). - // Seeded from saved swaps so existing wallets don't regress. - nextSwapIndex() { - const fromSaved = this.loadSwaps().reduce((m, s) => Math.max(m, (s.swapIndex ?? -1) + 1), 0); - let stored = 0; - try { stored = parseInt(localStorage.getItem(this._swapIdxKey()) || '0', 10) || 0; } catch {} - const idx = Math.max(fromSaved, stored); - try { localStorage.setItem(this._swapIdxKey(), String(idx + 1)); } catch {} - return idx; - }, - // Deterministic swap keypair on a dedicated chain (2) so swap keys never - // collide with receive (0) / change (1). Returns the HDKey node (has - // privateKey + publicKey, 33-byte compressed). - swapNode(index) { - return this.account().deriveChild(2).deriveChild(index); - }, - }); -} - -export class SwapManager { - constructor({ wallet, network = 'regtest', getApi, feeRate = 2, onUpdate }) { - this.wallet = wallet; - this.network = network; - this.getApi = getApi; // () => boltz REST base url - this.feeRate = feeRate; // sat/vB for funding + claim/refund - this.onUpdate = onUpdate || (() => {}); - this.timers = new Map(); - } - - list() { return this.wallet.loadSwaps(); } - // The swap (if any) an on-chain tx belongs to — its lockup funding (submarine), - // claim (reverse), or refund. Lets the tx detail screen show the swap context. - findByTxid(txid) { return this.list().find((s) => s.fundTxid === txid || s.claimTxid === txid || s.refundTxid === txid) || null; } - _get(id) { return this.wallet.loadSwaps().find((s) => s.id === id); } - _save(rec) { - const list = this.wallet.loadSwaps(); - const i = list.findIndex((s) => s.id === rec.id); - if (i >= 0) list[i] = rec; else list.push(rec); - this.wallet.saveSwaps(list); - this.onUpdate(rec); - } - _stop(id) { const t = this.timers.get(id); if (t) clearInterval(t); this.timers.delete(id); } - _spendFee() { return Math.max(250, Math.ceil(170 * this.feeRate)); } // ~1-input taproot spend - - // The selected provider's reverse-swap limits (sat), fetched + cached per API. - // Lets the UI bound the amount to what the provider actually accepts instead of - // a hardcoded guess (providers + networks differ). Falls back conservatively. - async reverseLimits() { - const api = this.getApi(); - if (this._revLim && this._revLimApi === api) return this._revLim; - let lim = { min: 25000, max: 25000000 }; - try { - const res = await boltzReq(api, '/v2/swap/reverse'); - const b = res && res.BTC && res.BTC.BTC && res.BTC.BTC.limits; - if (b && b.minimal) lim = { min: b.minimal, max: b.maximal }; - } catch {} - this._revLim = lim; this._revLimApi = api; - return lim; - } - - // REVERSE: hal receives over Lightning. Returns a record whose `.invoice` - // (bolt11) the user has someone pay; hal then claims the lockup on-chain. - async startReverse(amountSat) { - const w = this.wallet, net = this.network; - let idx, node, preimage, res; - // Our preimage is deterministic per swap index. If a prior attempt burned this - // index at Boltz ("swap with this preimage exists"), nextSwapIndex has advanced, - // so just retry with the next one. - for (let attempt = 0; ; attempt++) { - idx = w.nextSwapIndex(); - node = w.swapNode(idx); - preimage = sha256(node.privateKey); - try { - res = await boltzReq(this.getApi(), '/v2/swap/reverse', { - invoiceAmount: amountSat, from: 'BTC', to: 'BTC', - claimPublicKey: bytesToHex(node.publicKey), preimageHash: bytesToHex(sha256(preimage)), - }); - break; - } catch (e) { - if (attempt < 5 && /preimage|already exist/i.test(String(e?.message || e))) continue; - throw e; - } - } - const d = swapP2TR({ kind: 'reverse', preimageHash160: hash160(preimage), boltzPub33: hexToBytes(res.refundPublicKey), halPub33: node.publicKey, lockTime: res.timeoutBlockHeight, network: net }); - if (d.address !== res.lockupAddress) throw new Error('lockup address mismatch — not paying'); - if (bytesToHex(decodeBolt11(res.invoice).paymentHash) !== bytesToHex(sha256(preimage))) throw new Error('invoice hash mismatch'); - const rec = { id: res.id, kind: 'reverse', swapIndex: idx, status: 'awaiting-payment', network: net, invoice: res.invoice, lockupAddress: res.lockupAddress, onchainAmount: res.onchainAmount, refundPublicKey: res.refundPublicKey, timeoutBlockHeight: res.timeoutBlockHeight, createdAt: Date.now() }; - this._save(rec); - this._watchReverse(rec.id); - return rec; - } - - _watchReverse(id) { - if (this.timers.has(id)) return; - const w = this.wallet, net = this.network; - const tick = async () => { - const rec = this._get(id); - if (!rec || ['claimed', 'failed'].includes(rec.status)) return this._stop(id); - try { - const utxos = await w.api.addressUtxos(rec.lockupAddress); - if (!utxos || !utxos.length) return; - const u = utxos[0]; - const node = w.swapNode(rec.swapIndex); - const preimage = sha256(node.privateKey); - const d = swapP2TR({ kind: 'reverse', preimageHash160: hash160(preimage), boltzPub33: hexToBytes(rec.refundPublicKey), halPub33: node.publicKey, lockTime: rec.timeoutBlockHeight, network: net }); - const fee = this._spendFee(); - const destAddress = w.freshReceive().address; - const hex = buildSwapSpend({ pay: d.pay, leaf: d.claimLeaf, halPriv: node.privateKey, preimage, lockupTxid: u.txid, lockupVout: u.vout, lockupValue: u.value, destAddress, fee, network: net }); - const txid = await w.api.broadcast(hex); - rec.status = 'claimed'; rec.claimTxid = txid; rec.received = u.value - fee; - this._save(rec); this._stop(id); - // Credit the claimed coin (single output → destAddress) now, so the balance - // + generic "payment received" fire instantly instead of waiting for a scan. - if (w.creditReceive) w.creditReceive({ txid, vout: 0, value: rec.received, address: destAddress }); - } catch (e) { rec.lastError = String(e?.message || e); this.onUpdate(rec); } - }; - this.timers.set(id, setInterval(tick, 3000)); tick(); - } - - // SUBMARINE: hal spends over Lightning. Funds the lockup from the wallet; - // Boltz pays the invoice + claims. On failure, refund after the timeout. - async startSubmarine(invoice) { - return this.fundQuotedSubmarine(await this.quoteSubmarine(invoice)); - } - - // Quote a submarine swap WITHOUT committing funds: ask Boltz for the lockup - // amount + build (but don't broadcast) the funding tx, so the UI can show an - // itemized cost (amount + Boltz fee + on-chain network fee) before the user - // pays. Returns everything fundQuotedSubmarine() needs. - async quoteSubmarine(invoice) { - const w = this.wallet, net = this.network; - const dec = decodeBolt11(invoice); - const idx = w.nextSwapIndex(); - const node = w.swapNode(idx); - const res = await boltzReq(this.getApi(), '/v2/swap/submarine', { invoice, from: 'BTC', to: 'BTC', refundPublicKey: bytesToHex(node.publicKey) }); - const d = swapP2TR({ kind: 'submarine', preimageHash160: ripemd160(dec.paymentHash), boltzPub33: hexToBytes(res.claimPublicKey), halPub33: node.publicKey, lockTime: res.timeoutBlockHeight, network: net }); - if (d.address !== res.address) throw new Error('lockup address mismatch — not funding'); - const draft = w.buildTx({ recipients: [{ address: res.address, amount: res.expectedAmount }], feeRate: this.feeRate }); - const invoiceAmount = dec.amountMsat ? Number(dec.amountMsat / 1000n) : null; - return { - invoice, id: res.id, swapIndex: idx, lockupAddress: res.address, expectedAmount: res.expectedAmount, - claimPublicKey: res.claimPublicKey, timeoutBlockHeight: res.timeoutBlockHeight, paymentHash: bytesToHex(dec.paymentHash), - invoiceAmount, networkFee: draft.fee, boltzFee: invoiceAmount != null ? res.expectedAmount - invoiceAmount : null, - total: res.expectedAmount + draft.fee, _draft: draft, - }; - } - - // Broadcast the funding tx for a previously-quoted submarine swap + watch it. - async fundQuotedSubmarine(q) { - const w = this.wallet, net = this.network; - const rec = { id: q.id, kind: 'submarine', swapIndex: q.swapIndex, status: 'funding', network: net, paymentHash: q.paymentHash, lockupAddress: q.lockupAddress, expectedAmount: q.expectedAmount, claimPublicKey: q.claimPublicKey, timeoutBlockHeight: q.timeoutBlockHeight, invoiceAmount: q.invoiceAmount, createdAt: Date.now() }; - this._save(rec); - const fundTxid = await w.broadcast(w.sign(q._draft.tx)); - if (w.applySentTx) w.applySentTx(q._draft.tx); - rec.fundTxid = fundTxid; rec.status = 'paying'; this._save(rec); - this._watchSubmarine(rec.id); - return rec; - } - - _watchSubmarine(id) { - if (this.timers.has(id)) return; - // For a submarine swap the success moment is invoice.paid — Boltz has paid the - // Lightning invoice (the whole point). It then claims its on-chain lockup - // (transaction.claim.pending → claimed), often deferred to a later batch, but - // our funds are already gone to the LN destination, so we're done either way. - const done = new Set(['invoice.paid', 'transaction.claim.pending', 'transaction.claimed', 'invoice.settled']); - const bad = new Set(['invoice.failedToPay', 'swap.expired', 'transaction.lockupFailed', 'transaction.refunded']); - const tick = async () => { - const rec = this._get(id); - if (!rec || ['success', 'refunded', 'failed'].includes(rec.status)) return this._stop(id); - try { - const s = await boltzReq(this.getApi(), `/v2/swap/${rec.id}`); - rec.boltzStatus = s.status; - if (done.has(s.status)) { rec.status = 'success'; this._save(rec); this._stop(id); return; } - if (bad.has(s.status)) { rec.status = 'refundable'; this._save(rec); this._stop(id); return; } - this._save(rec); - } catch (e) { rec.lastError = String(e?.message || e); this.onUpdate(rec); } - }; - this.timers.set(id, setInterval(tick, 3000)); tick(); - } - - // Refund a failed submarine swap on-chain via the timeout script path. Only - // succeeds once chain height >= timeoutBlockHeight (CLTV). - async refundSubmarine(id) { - const w = this.wallet, net = this.network; - const rec = this._get(id); - if (!rec || rec.kind !== 'submarine') throw new Error('not a submarine swap'); - const utxos = await w.api.addressUtxos(rec.lockupAddress); - if (!utxos || !utxos.length) throw new Error('no lockup utxo to refund'); - const u = utxos[0]; - const node = w.swapNode(rec.swapIndex); - const d = swapP2TR({ kind: 'submarine', preimageHash160: ripemd160(hexToBytes(rec.paymentHash)), boltzPub33: hexToBytes(rec.claimPublicKey), halPub33: node.publicKey, lockTime: rec.timeoutBlockHeight, network: net }); - const fee = this._spendFee(); - const hex = buildSwapSpend({ pay: d.pay, leaf: d.refundLeaf, halPriv: node.privateKey, preimage: null, lockupTxid: u.txid, lockupVout: u.vout, lockupValue: u.value, destAddress: w.freshReceive().address, fee, lockTime: rec.timeoutBlockHeight, network: net }); - const txid = await w.api.broadcast(hex); - rec.status = 'refunded'; rec.refundTxid = txid; this._save(rec); - return txid; - } - - // Re-attach watchers to non-final swaps after a page reload. - resumeAll() { - for (const rec of this.list()) { - if (rec.kind === 'reverse' && !['claimed', 'failed'].includes(rec.status)) this._watchReverse(rec.id); - else if (rec.kind === 'submarine' && ['funding', 'paying'].includes(rec.status)) this._watchSubmarine(rec.id); - } - } -} diff --git a/src/wallet.js b/src/wallet.js index 2417383..015a396 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -1229,21 +1229,6 @@ export class Wallet { - // Number of unreserved spendable coins (for the consolidate prompt/action). - spendableCoinCount() { - const locked = this.lockedCoinIds(); - return this.utxos.filter((u) => !locked.has(utxoId(u)) && (u.confirmed || u.chain === 1)).length; - } - - // Merge all unreserved spendable coins into one, via a max self-send. Leaves - // the wallet with a single coin so future sends/gifts use one input. - async consolidate(feeRate) { - const rate = Math.max(1, Math.round(feeRate)); - const draft = this.buildTx({ recipients: [{ address: this.freshReceive().address }], feeRate: rate, sendMax: true }); - const hex = this.sign(draft.tx); - await this.broadcast(hex); - return { txid: draft.tx.id, inputs: draft.inputsCount }; - } |
