diff options
Diffstat (limited to 'src/features')
| -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 |
6 files changed, 117 insertions, 1211 deletions
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; }, - }; -} |
