From 348a57004f94dd0f58c4e70769e65ca7ea3f7619 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 1 Aug 2026 19:38:30 -0700 Subject: Simplify: one Lightning path, one balance, fewer features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dropped: silent payments (feature, wallet chains, scan worker, indexer config), Boltz submarine/reverse swaps (feature + swap.js + tools), the third-party ark swap bridge (config, client flows, settings card — the ASP's own Lightning send is the one path now), and the Consolidate coins settings action. The NWC pouch is gone with them: background answering now spends the WHOLE ark balance. The IndexedDB record mirrors the real manager state plus the chain-3 keys for its live coins and a change-index window; the worker pays through the ASP exactly like the open app, and the app folds the worker's spends, change, and key allocations back in on connect (additive merge + reconcile against the server). A leftover chain-6 pouch from the short-lived pouch era is swept home automatically using the keys stored in its own record. The stated tradeoff: same-origin script access now reaches the ark balance, not a carve-out — the ark balance is walking-around money, the on-chain wallet's keys are never mirrored, and per-connection NWC budgets remain the enforced ceiling. Bundle: 726KB -> 602KB. Co-Authored-By: Claude Fable 5 --- src/features/ark.js | 362 ++++++++++++---------------------------------------- 1 file changed, 80 insertions(+), 282 deletions(-) (limited to 'src/features/ark.js') 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() { -- cgit v1.2.3