diff options
Diffstat (limited to 'src/features')
| -rw-r--r-- | src/features/ark.js | 112 | ||||
| -rw-r--r-- | src/features/nwc.js | 84 |
2 files changed, 185 insertions, 11 deletions
diff --git a/src/features/ark.js b/src/features/ark.js index 8456f69..c163583 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -7,6 +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 { boardFee } from '../ark/board.js'; import { maybeBolt11, lnSendFee } from '../ark/lightning.js'; import { decodeVtxo, getVtxoStatus, VTXO_STATE_SPENT, concatBytes } from '../ark/proto.js'; @@ -530,6 +531,28 @@ export function arkFeature(ctx) { return res; } + // 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) { + 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(() => {}); }, + }; + return new ArkManager({ + account: account6, storage, + 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(); @@ -1676,11 +1699,96 @@ export function arkFeature(ctx) { return { invoice: a.invoice, paymentHash: a.paymentHash, amountSat }; }, arkMovements() { const s = arkStateNow(); return s ? (s.movements || []) : []; }, - // endpoints + server identity for the NWC pouch record + // 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 }; + 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); + }, + // Pouch payments become ordinary history lines once the app opens. + async arkPouchNoteSpends(spends) { + const mgr = await connectArk(); + for (const s 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 || '', + }); + } + mgr._save(); }, // An npub pasted into Send becomes an ark zap (needs the nostr seam from // the sync feature and a connected-able ark). A bolt11 is handled here diff --git a/src/features/nwc.js b/src/features/nwc.js index 51c0fd6..a91768d 100644 --- a/src/features/nwc.js +++ b/src/features/nwc.js @@ -30,7 +30,7 @@ import { import { t } from '../i18n.js'; import { qrSvg } from '../qr.js'; import { getNetwork } from '../api.js'; -import { buildPouch, savePouch, clearPouch, pouchKey } from '../nwc-pouch.js'; +import { buildPouch, loadPouch, savePouch, clearPouch, pouchKey } from '../nwc-pouch.js'; import { maybeBolt11 } from '../ark/lightning.js'; const REQ_KIND = 23194; @@ -496,20 +496,55 @@ export function nwcFeature(ctx) { try { await clearPouch(wallet._cacheKey()); } catch {} } - // Mirror just enough for a future worker-side responder: the connections' - // service keys and the ASP endpoints. No pouch coins are moved here — the - // funding UI comes with the spend path. + // (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() { if (!load().background) return; const cfg = hook('arkPouchContext'); if (!cfg) return; try { - await savePouch(wallet._cacheKey(), buildPouch({ - ...cfg, vtxos: [], account: wallet.account(), connections: conns(), - })); + 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); } } + // 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() { + 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); + 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); + } + pouchSat = await hook('arkPouchBalance'); + render(); + } catch (e) { console.warn('nwc: pouch reconcile failed', e.message); } + } + // An open window handles the request itself; the worker just nudges it. if (typeof navigator !== 'undefined' && navigator.serviceWorker) { navigator.serviceWorker.addEventListener('message', (ev) => { @@ -580,11 +615,42 @@ export function nwcFeature(ctx) { h('button', { class: 'btn-sm', onClick: async () => { try { if (load().background) { await disableBackground(); toast(t('nwcBackgroundOff')); } - else { await enableBackground(); toast(t('nwcBackgroundOn')); } + else { await enableBackground(); await reconcilePouch(); 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, @@ -611,7 +677,7 @@ export function nwcFeature(ctx) { return { id: 'nwc', - init() { listen(); startWatchdog(); refreshRegistration(); }, + init() { listen(); startWatchdog(); refreshRegistration(); reconcilePouch(); }, stop() { stop(); if (watchdog) { clearInterval(watchdog); watchdog = null; } }, settingsCards() { return [nwcCard()]; }, }; |
