aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-01 19:02:25 -0700
committerAdam Soltys <[email protected]>2026-08-01 19:02:25 -0700
commitb73bb9ee51b9be33bc1859fd56d3e11905c3d456 (patch)
tree8a008d57e450961d1ab203a663e9d8fa233a2686 /src
parent2a7a825ce5850a2a836effae820adbae0b66d093 (diff)
downloadcoinosv3-b73bb9ee51b9be33bc1859fd56d3e11905c3d456.tar.gz
coinosv3-b73bb9ee51b9be33bc1859fd56d3e11905c3d456.zip
Background NWC: the worker now answers — and pays — from the pouch
The no-tap half of background wallet-connect. The push payload carries the (encrypted, client-signed) request event; a bundled service-worker responder (src/sw-nwc.js -> nwc-respond.js) finds the matching pouch in IndexedDB, decrypts with the connection's service key, and answers get_info/get_balance/list_transactions from pouch state. pay_invoice pays through the swap bridge with an ArkManager running over the pouch's chain-6 key window (one HTLC lock, one HTTP swap), enforcing per-payment and daily budgets from the pouch's own spend log, checking the notifier's /answered endpoint so it never double-pays or shouts over an open device, and persisting the swap before handing it off so the app can refund a crashed flight. Anything it can't handle falls back to the wake-the-user notification. The notifier grows /publish (verified 23195s only, rate-limited) as the worker's reply pipe and /answered for race avoidance. The app gains pouch funding (mailbox-less arkoor self-send whose recipient bytes we keep), emptying, spend/budget reconciliation on open, and refund recovery with the pouch's own keys. Co-Authored-By: Claude Fable 5 <[email protected]>
Diffstat (limited to 'src')
-rw-r--r--src/ark/manager.js34
-rw-r--r--src/features/ark.js112
-rw-r--r--src/features/nwc.js84
-rw-r--r--src/i18n.js13
-rw-r--r--src/nwc-pouch.js38
-rw-r--r--src/nwc-respond.js245
-rw-r--r--src/sw-nwc.js49
7 files changed, 552 insertions, 23 deletions
diff --git a/src/ark/manager.js b/src/ark/manager.js
index e70acfb..2edcd8a 100644
--- a/src/ark/manager.js
+++ b/src/ark/manager.js
@@ -500,6 +500,32 @@ export class ArkManager {
return action.id;
}
+ // A send whose recipient is one of the USER's own foreign-branch keys (the
+ // NWC pouch): no address, no mailbox delivery — the caller receives the
+ // signed recipient vtxo bytes directly and stores them wherever the branch
+ // lives. Same cosign machinery as send(), so all its failure handling holds.
+ async sendToPubkey(destPubkeyHex, amountSat) {
+ const input = this._selectInput(amountSat);
+ const changeSat = input.amountSat - amountSat;
+ const action = {
+ id: `send-${Date.now()}`, type: 'send', step: 'created',
+ inputId: input.id, amountSat, destAddress: null,
+ destPubkey: destPubkeyHex, destBlindedId: null,
+ changeIndex: changeSat > 0 ? this.state.nextKeyIndex++ : null, changeSat,
+ };
+ input.state = 'pending';
+ this.state.actions.push(action);
+ this._save();
+ await this._driveSend(action);
+ if (action.step !== 'done') throw new Error(action.error || 'send did not complete');
+ return {
+ vtxos: (action.destBytesList || []).map((b) => {
+ const v = decodeVtxo(hex.decode(b));
+ return { id: v.id, bytes: b, amountSat: v.amountSat, expiryHeight: v.expiryHeight };
+ }),
+ };
+ }
+
_sendOutputs(action) {
const outputs = [{ amountSat: action.amountSat, userPubkey: hex.decode(action.destPubkey) }];
if (action.changeSat > 0) {
@@ -558,8 +584,12 @@ export class ArkManager {
this._save();
}
if (action.step === 'registered') {
- await postArkoorMessage(this.arkUrl, hex.decode(action.destBlindedId),
- destList().map((b) => hex.decode(b)));
+ // a pouch-bound send has no mailbox to deliver to — the caller takes
+ // the bytes from the returned action instead
+ if (action.destBlindedId) {
+ await postArkoorMessage(this.arkUrl, hex.decode(action.destBlindedId),
+ destList().map((b) => hex.decode(b)));
+ }
for (const b of changeList()) {
const change = this._vtxo(decodeVtxo(hex.decode(b)).id);
if (change && change.state === 'pending') change.state = 'spendable';
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()]; },
};
diff --git a/src/i18n.js b/src/i18n.js
index 1fa7d74..327bf01 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -439,11 +439,18 @@ const STR = {
nwcDesc: 'Let another app pay from this wallet over nostr — it never sees your keys, only spends within the limits you set, and you can cut it off at any time. Payments go out over Ark.',
nwcTabWarning: 'Only works while Hal is open in a tab — it is not an always-on service.',
nwcAdd: 'Add a connection',
- nwcBackground: 'Alert me when closed',
+ nwcBackground: 'Answer while closed',
nwcOn: 'On',
nwcOff: 'Off',
- nwcBackgroundOn: 'You will be notified of payment requests',
- nwcBackgroundOff: 'Background alerts turned off',
+ nwcBackgroundOn: 'Requests will be answered in the background',
+ nwcBackgroundOff: 'Background answering turned off',
+ nwcPouch: 'Pouch (spendable while closed)',
+ nwcPouchDesc: 'Coins in the pouch can be spent by the background responder without opening Hal. Keep it small — top it up as it drains.',
+ nwcPouchFund: 'Add to pouch',
+ nwcPouchEmpty: 'Empty pouch',
+ nwcPouchFunded: 'Moved {sats} sats into the pouch',
+ nwcPouchEmptied: 'Moved {sats} sats back to the wallet',
+ nwcPouchAmount: 'Amount (sats)',
nwcAppName: 'App name (e.g. Damus)',
nwcMaxPayment: 'Max per payment (sats)',
nwcDailyBudget: 'Daily budget (sats)',
diff --git a/src/nwc-pouch.js b/src/nwc-pouch.js
index 0d4fcbc..187ab7a 100644
--- a/src/nwc-pouch.js
+++ b/src/nwc-pouch.js
@@ -73,6 +73,23 @@ const get = (s, k) => new Promise((res, rej) => { const r = s.get(k); r.onsucces
export async function loadPouch(walletKey) {
try { return (await tx('readonly', (s) => get(s, walletKey))) || null; } catch { return null; }
}
+// The service worker doesn't know wallet cache keys — it finds the right
+// pouch by the connection's service pubkey across all of them.
+export async function allPouches() {
+ try {
+ return await tx('readonly', (s) => new Promise((res, rej) => {
+ const out = [];
+ const cur = s.openCursor();
+ cur.onsuccess = () => {
+ const c = cur.result;
+ if (!c) return res(out);
+ out.push({ walletKey: c.key, pouch: c.value });
+ c.continue();
+ };
+ cur.onerror = () => rej(cur.error);
+ }));
+ } catch { return []; }
+}
export async function savePouch(walletKey, pouch) {
return tx('readwrite', (s) => { s.put(pouch, walletKey); });
}
@@ -80,25 +97,32 @@ export async function clearPouch(walletKey) {
return tx('readwrite', (s) => { s.delete(walletKey); });
}
+// The worker can't derive keys (no seed), so the pouch carries a fixed window
+// of pre-derived chain-6 keys: funding uses low indices, the worker's change
+// and HTLC-refund outputs allocate upward from POUCH_CHANGE_BASE.
+export const POUCH_KEY_WINDOW = 50;
+export const POUCH_CHANGE_BASE = 20;
+
// Everything the service worker needs to spend, and nothing more: the ASP
-// endpoints, the pouch vtxos with their bytes, and the private keys for
-// exactly those vtxos.
-export function buildPouch({ arkUrl, esploraUrl, network, serverPubkey, vtxos, account, connections }) {
+// endpoints, the bridge, the pouch vtxos with their bytes, and the private
+// keys for exactly the pouch's key window.
+export function buildPouch({ arkUrl, esploraUrl, network, serverPubkey, bridge, vtxos, account, connections }) {
const keys = {};
- for (const v of vtxos) {
- const k = pouchKey(account, v.keyIndex);
- keys[String(v.keyIndex)] = hex.encode(k.privkey);
+ for (let i = 0; i < POUCH_KEY_WINDOW; i++) {
+ keys[String(i)] = hex.encode(pouchKey(account, i).privkey);
}
return {
- v: 1,
+ v: 2,
updated: Date.now(),
ark: { arkUrl, esploraUrl, network, serverPubkey },
+ bridge: bridge || null, // { url, token } — the worker pays through this
// [{ id, bytes, keyIndex, amountSat, expiryHeight }]
vtxos: vtxos.map((v) => ({
id: v.id, bytes: v.bytes, keyIndex: v.keyIndex,
amountSat: v.amountSat, expiryHeight: v.expiryHeight,
})),
keys,
+ nextKeyIndex: POUCH_CHANGE_BASE,
// service keys for the connections the SW may answer for, so it can
// decrypt requests and sign replies without the wallet
connections: (connections || []).map((c) => ({
diff --git a/src/nwc-respond.js b/src/nwc-respond.js
new file mode 100644
index 0000000..e7bb716
--- /dev/null
+++ b/src/nwc-respond.js
@@ -0,0 +1,245 @@
+// NWC auto-answer from the pouch — the half of background wallet-connect
+// that pays without a tap. Runs inside the service worker's push handler:
+// the push payload carries the encrypted request event, the pouch (IndexedDB)
+// carries the connection service keys and a bounded set of coins, and the
+// reply goes back through the notifier's /publish endpoint — no WebSocket,
+// no seed, nothing outside the pouch's blast radius.
+//
+// Trust: the notifier transports ciphertext both ways. Requests arrive
+// nip04/nip44-encrypted and signed by the client; replies leave encrypted and
+// signed by the per-connection service key. The notifier can neither read nor
+// forge either. Spending happens directly against the ASP/bridge over fetch.
+//
+// Anything this module can't handle returns false, and the caller falls back
+// to today's behavior: a notification that opens the wallet.
+
+import { hex } from '@scure/base';
+import { sha256 } from '@noble/hashes/sha256';
+import * as nip04 from 'nostr-tools/nip04';
+import * as nip44 from 'nostr-tools/nip44';
+import { finalizeEvent } from 'nostr-tools/pure';
+import { ArkManager } from './ark/manager.js';
+import { maybeBolt11 } from './ark/lightning.js';
+import { allPouches, savePouch, POUCH_KEY_WINDOW, POUCH_CHANGE_BASE } from './nwc-pouch.js';
+
+const MAX_AGE_SEC = 120;
+const nowSec = () => Math.floor(Date.now() / 1000);
+const errRes = (t, code, message) => ({ result_type: t, error: { code, message } });
+
+const decrypt = async (scheme, skHex, pk, ct) => (scheme === 'nip44_v2'
+ ? nip44.decrypt(ct, nip44.getConversationKey(hex.decode(skHex), pk))
+ : Promise.resolve(nip04.decrypt(hex.decode(skHex), pk, ct)));
+
+async function buildReply(conn, ev, scheme, payload) {
+ const sk = hex.decode(conn.serviceSk);
+ const content = scheme === 'nip44_v2'
+ ? nip44.encrypt(JSON.stringify(payload), nip44.getConversationKey(sk, ev.pubkey))
+ : await Promise.resolve(nip04.encrypt(sk, ev.pubkey, JSON.stringify(payload)));
+ return finalizeEvent({
+ kind: 23195, created_at: nowSec(),
+ tags: [['p', ev.pubkey], ['e', ev.id], ['encryption', scheme]],
+ content,
+ }, sk);
+}
+
+const seedState = (pouch) => ({
+ 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: [],
+});
+
+const spendableVtxos = (pouch) =>
+ ((pouch.mgr && pouch.mgr.vtxos) || seedState(pouch).vtxos).filter((v) => v.state === 'spendable');
+
+// data: the push payload { type:'nwc', servicePubkey, event }. Returns true
+// when the request was fully dealt with (reply published or deliberately
+// left to another device); false means "wake the user instead".
+export async function respondFromPouch(data, {
+ notifier, fetchFn = fetch, pouchesFn = allPouches, saveFn = savePouch, log = () => {},
+} = {}) {
+ const ev = data && data.event;
+ if (!ev || ev.kind !== 23194 || !ev.id || !ev.pubkey) return false;
+ if (nowSec() - (ev.created_at || 0) > MAX_AGE_SEC) return false;
+
+ const target = (ev.tags?.find((t) => t[0] === 'p') || [])[1];
+ if (!target) return false;
+ let walletKey = null, pouch = null, conn = null;
+ for (const p of await pouchesFn()) {
+ const c = (p.pouch.connections || []).find((x) => x.servicePk === target);
+ if (c) { walletKey = p.walletKey; pouch = p.pouch; conn = c; break; }
+ }
+ if (!conn) return false;
+ if (ev.pubkey !== conn.clientPk) return false;
+
+ const scheme = ev.tags?.find((x) => x[0] === 'encryption')?.[1] === 'nip44_v2' ? 'nip44_v2' : 'nip04';
+ let req;
+ try { req = JSON.parse(await decrypt(scheme, conn.serviceSk, ev.pubkey, ev.content)); } catch { return false; }
+ const method = req?.method;
+ const params = req?.params || {};
+
+ const publish = async (payload) => {
+ const evt = await buildReply(conn, ev, scheme, payload);
+ const r = await fetchFn(`${notifier}/publish`, {
+ method: 'POST', headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ event: evt }),
+ });
+ if (!r.ok) throw new Error(`publish refused: ${r.status}`);
+ return true;
+ };
+ // Another device (an open tab elsewhere) may have answered already — a
+ // paid zap must never be shouted over with a late error, and paying twice
+ // wastes a lock cycle. The notifier tracks recent reply e-tags.
+ const answered = async () => {
+ try {
+ const r = await fetchFn(`${notifier}/answered?event=${ev.id}`);
+ return !!(await r.json()).answered;
+ } catch { return false; }
+ };
+
+ if (method === 'get_info') {
+ await publish({
+ result_type: 'get_info',
+ result: {
+ alias: 'Hal', color: '#f7931a',
+ network: pouch.ark.network === 'mainnet' ? 'mainnet' : pouch.ark.network,
+ block_height: 0, block_hash: '',
+ methods: ['get_info', 'get_balance', 'pay_invoice', 'list_transactions'],
+ },
+ });
+ return true;
+ }
+ if (method === 'get_balance') {
+ const sat = spendableVtxos(pouch).reduce((n, v) => n + (v.amountSat || 0), 0);
+ await publish({ result_type: 'get_balance', result: { balance: sat * 1000 } });
+ return true;
+ }
+ if (method === 'list_transactions') {
+ const txs = (pouch.spends || []).slice(-(params.limit || 20)).map((s) => ({
+ type: 'outgoing', invoice: s.invoice || '', preimage: s.preimage || '',
+ amount: (s.amountSat || 0) * 1000, fees_paid: (s.feeSat || 0) * 1000,
+ created_at: Math.floor((s.ts || Date.now()) / 1000),
+ settled_at: Math.floor((s.ts || Date.now()) / 1000),
+ description: '',
+ }));
+ await publish({ result_type: 'list_transactions', result: { transactions: txs } });
+ return true;
+ }
+ if (method !== 'pay_invoice') {
+ await publish(errRes(method, 'NOT_IMPLEMENTED', 'not available while the wallet is closed'));
+ return true;
+ }
+
+ // ---- pay_invoice ------------------------------------------------------
+ if (await answered()) { log('already answered elsewhere'); return true; }
+
+ const invoice = params.invoice;
+ const dec = invoice && maybeBolt11(invoice);
+ if (!dec) { await publish(errRes('pay_invoice', 'OTHER', 'not a bolt11 invoice')); return true; }
+ if (!dec.amountSat) { await publish(errRes('pay_invoice', 'OTHER', 'zero-amount invoices are not supported')); return true; }
+ if (dec.amountSat > conn.maxSat) {
+ await publish(errRes('pay_invoice', 'QUOTA_EXCEEDED', `over the ${conn.maxSat} sat per-payment limit`));
+ return true;
+ }
+ const today = new Date().toISOString().slice(0, 10);
+ const spentToday = (pouch.spends || [])
+ .filter((s) => s.connId === conn.id && new Date(s.ts).toISOString().slice(0, 10) === today)
+ .reduce((n, s) => n + (s.amountSat || 0) + (s.feeSat || 0), 0);
+ if (spentToday + dec.amountSat > conn.dailySat) {
+ await publish(errRes('pay_invoice', 'QUOTA_EXCEEDED', `over the remaining daily budget (${Math.max(0, conn.dailySat - spentToday)} sat)`));
+ return true;
+ }
+
+ // The worker only pays through the bridge (one HTLC lock, one HTTP swap).
+ // No bridge, no coins, or an exhausted key window → wake the user instead.
+ if (!pouch.bridge || !pouch.bridge.url) return false;
+ const nextIdx = (pouch.mgr && pouch.mgr.nextKeyIndex) || pouch.nextKeyIndex || POUCH_CHANGE_BASE;
+ if (nextIdx >= POUCH_KEY_WINDOW - 1) { log('pouch key window exhausted'); return false; }
+
+ const auth = pouch.bridge.token ? { authorization: `Bearer ${pouch.bridge.token}` } : {};
+ let quote;
+ try {
+ const r = await fetchFn(`${pouch.bridge.url}/quote?invoice=${encodeURIComponent(invoice)}`, { headers: auth });
+ quote = await r.json();
+ if (!r.ok || quote.error) throw new Error(quote.error || `quote failed (${r.status})`);
+ } catch (e) { log('quote failed: ' + e.message); return false; }
+ if (!spendableVtxos(pouch).some((v) => v.amountSat >= quote.totalSat)) {
+ log('pouch cannot cover ' + quote.totalSat);
+ return false; // underfunded pouch: the user should top up — notify
+ }
+
+ const storage = {
+ load: () => pouch.mgr || seedState(pouch),
+ save: (s) => { pouch.mgr = s; saveFn(walletKey, pouch).catch(() => {}); },
+ };
+ const keyShim = { deriveChild: () => ({ deriveChild: (i) => ({ privateKey: hex.decode(pouch.keys[String(i)] || pouch.keys['0']) }) }) };
+ let mgr;
+ try {
+ mgr = await new ArkManager({
+ account: keyShim, storage,
+ arkUrl: pouch.ark.arkUrl, esploraUrl: pouch.ark.esploraUrl, network: pouch.ark.network,
+ }).init();
+ } catch (e) { log('ark init failed: ' + e.message); return false; }
+
+ let lock;
+ try {
+ lock = await mgr.htlcLock({
+ amountSat: quote.totalSat, claimPubkey: quote.claimPubkey,
+ paymentHash: quote.paymentHash, htlcExpiry: quote.htlcExpiry,
+ });
+ } catch (e) { log('htlc lock failed: ' + e.message); return false; }
+
+ // Persist the swap BEFORE handing it to the bridge: if we die mid-flight,
+ // the app finds the record on next open and runs its refund machinery.
+ pouch.swaps = pouch.swaps || [];
+ const rec = {
+ paymentHash: quote.paymentHash, invoice, bridgeUrl: pouch.bridge.url,
+ amountSat: quote.amountSat, feeSat: quote.feeSat,
+ htlcVtxo: lock.htlcVtxos[0], refundIndex: lock.refundIndex,
+ htlcExpiry: quote.htlcExpiry, step: 'submitted', ts: Date.now(),
+ };
+ pouch.swaps.push(rec);
+ await saveFn(walletKey, pouch);
+
+ let res;
+ try {
+ const r = await fetchFn(`${pouch.bridge.url}/swap`, {
+ method: 'POST', headers: { 'content-type': 'application/json', ...auth },
+ 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})`);
+ if (res.step !== 'done') throw new Error(res.error || 'bridge did not pay');
+ if (!res.preimage || hex.encode(sha256(hex.decode(res.preimage))) !== quote.paymentHash) {
+ throw new Error('bridge returned an invalid preimage');
+ }
+ } catch (e) {
+ rec.step = 'refundable';
+ rec.error = e.message;
+ await saveFn(walletKey, pouch);
+ // the winner may have paid while we were locking — never shout over it
+ if (!(await answered())) {
+ await publish(errRes('pay_invoice', 'INTERNAL', e.message)).catch(() => {});
+ }
+ return true;
+ }
+
+ rec.step = 'done';
+ rec.preimage = res.preimage;
+ pouch.spends = pouch.spends || [];
+ pouch.spends.push({
+ connId: conn.id, amountSat: quote.amountSat, feeSat: quote.feeSat,
+ paymentHash: quote.paymentHash, preimage: res.preimage, invoice, ts: Date.now(),
+ });
+ if (pouch.spends.length > 200) pouch.spends = pouch.spends.slice(-200);
+ await saveFn(walletKey, pouch);
+ await publish({
+ result_type: 'pay_invoice',
+ result: { preimage: res.preimage, fees_paid: (quote.feeSat || 0) * 1000 },
+ });
+ log(`paid ${quote.amountSat} sat via bridge from the pouch`);
+ return true;
+}
diff --git a/src/sw-nwc.js b/src/sw-nwc.js
new file mode 100644
index 0000000..db57b99
--- /dev/null
+++ b/src/sw-nwc.js
@@ -0,0 +1,49 @@
+// Service-worker side of background NWC. Bundled by build.js and appended to
+// the generated sw.js, replacing the notify-only push handler: a push now
+// first tries to ANSWER the request from the pouch (see nwc-respond.js), and
+// only falls back to the wake-the-user notification when it can't.
+
+import { respondFromPouch } from './nwc-respond.js';
+
+const NOTIFIER = 'https://nwcpush.coinos.io';
+
+self.addEventListener('push', (e) => {
+ let data = {};
+ try { data = e.data ? e.data.json() : {}; } catch (_) {}
+ if (data.type !== 'nwc') return;
+ e.waitUntil((async () => {
+ // An open window handles requests itself with full wallet state — nudge
+ // it and stay out of the way.
+ const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
+ if (clients.length) {
+ for (const c of clients) c.postMessage({ type: 'nwc-wake', servicePubkey: data.servicePubkey });
+ return;
+ }
+ let handled = false;
+ try {
+ handled = await respondFromPouch(data, {
+ notifier: NOTIFIER,
+ log: (m) => console.log('[sw-nwc]', m),
+ });
+ } catch (err) {
+ console.warn('[sw-nwc] auto-answer failed:', err && err.message);
+ }
+ if (handled) return;
+ await self.registration.showNotification('Payment request', {
+ body: 'An app is asking your wallet to pay. Open Hal to approve.',
+ icon: 'icon-192.png', badge: 'icon-192.png',
+ tag: 'nwc-' + (data.servicePubkey || 'req'), renotify: false,
+ data: { url: './' },
+ });
+ })());
+});
+
+self.addEventListener('notificationclick', (e) => {
+ e.notification.close();
+ e.waitUntil((async () => {
+ const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
+ const open = all.find((c) => 'focus' in c);
+ if (open) return open.focus();
+ if (self.clients.openWindow) return self.clients.openWindow('./');
+ })());
+});