aboutsummaryrefslogtreecommitdiffstats
path: root/src/features
diff options
context:
space:
mode:
Diffstat (limited to 'src/features')
-rw-r--r--src/features/ark.js112
-rw-r--r--src/features/nwc.js84
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()]; },
};