aboutsummaryrefslogtreecommitdiffstats
path: root/src/features
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-01 19:38:30 -0700
committerAdam Soltys <[email protected]>2026-08-01 19:38:30 -0700
commit348a57004f94dd0f58c4e70769e65ca7ea3f7619 (patch)
tree5f8cd2930abd9c1e05bb7c18e0bec239780281ef /src/features
parent9196f4804851a87c8356418b1c4cade6e87c54ba (diff)
downloadcoinosv3-348a57004f94dd0f58c4e70769e65ca7ea3f7619.tar.gz
coinosv3-348a57004f94dd0f58c4e70769e65ca7ea3f7619.zip
Simplify: one Lightning path, one balance, fewer features
Dropped: silent payments (feature, wallet chains, scan worker, indexer config), Boltz submarine/reverse swaps (feature + swap.js + tools), the third-party ark swap bridge (config, client flows, settings card — the ASP's own Lightning send is the one path now), and the Consolidate coins settings action. The NWC pouch is gone with them: background answering now spends the WHOLE ark balance. The IndexedDB record mirrors the real manager state plus the chain-3 keys for its live coins and a change-index window; the worker pays through the ASP exactly like the open app, and the app folds the worker's spends, change, and key allocations back in on connect (additive merge + reconcile against the server). A leftover chain-6 pouch from the short-lived pouch era is swept home automatically using the keys stored in its own record. The stated tradeoff: same-origin script access now reaches the ark balance, not a carve-out — the ark balance is walking-around money, the on-chain wallet's keys are never mirrored, and per-connection NWC budgets remain the enforced ceiling. Bundle: 726KB -> 602KB. Co-Authored-By: Claude Fable 5 <[email protected]>
Diffstat (limited to 'src/features')
-rw-r--r--src/features/ark.js362
-rw-r--r--src/features/index.js7
-rw-r--r--src/features/nwc.js110
-rw-r--r--src/features/sp-wallet.js544
-rw-r--r--src/features/sp.js66
-rw-r--r--src/features/swaps.js239
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; },
- };
-}