From 1139c6beafb1199c149bd1f51a46d817e1d46173 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 6 Jul 2026 05:37:42 -0700 Subject: NWC bridge funding (PoC): ark wallets behind a lightning facade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compatibility play for ark zaps: an NWC wallet service (coinos, eventually) receives pay_invoice from any nostr client, asks the hal wallet to cover the amount over ark (kind 23196 funding request), hal auto-pays within a user-set allowance and acks (23197), the bridge receives the vtxo in its mailbox and settles the bolt11 over lightning. Custody window is the seconds the sats are in flight. hal side: a live nostr subscription primitive (nostrSubscribe through the sync seam) and an allowance-gated funding handler that arms when ark connects. PoC config via localStorage 'btc-wallet-arknwc' ({bridgePk, budgetSat}); proper UI when the protocol settles. Proven end-to-end on regtest (tools/.arknwc-e2e.tmp.js): mini relay + bridge process (ArkManager in bun + CLN) + NIP-47 client — invoice on clb paid via cl, preimage returned over NWC, hal debited 1,000 sats over ark, zero page errors. Co-Authored-By: Claude Fable 5 --- src/features/ark.js | 46 ++++++++++++++++++++++++++++++++++++++++++++++ src/features/sync.js | 6 ++++++ src/nostr.js | 8 ++++++++ 3 files changed, 60 insertions(+) (limited to 'src') diff --git a/src/features/ark.js b/src/features/ark.js index a6959f2..fe1aab3 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -53,6 +53,7 @@ export function arkFeature(ctx) { if (arkTimer) clearInterval(arkTimer); arkTimer = null; if (ark) ark.stopMailboxStream(); + stopNwcFunding(); ark = null; arkConnectPromise = null; } @@ -92,6 +93,7 @@ export function arkFeature(ctx) { if (gen !== arkInitGen) throw new Error('superseded'); // wallet switched mid-connect ark = mgr; announceArkAddress(mgr); // ark zaps: tell nostr where our mailbox lives + startNwcFunding(mgr); // NWC bridge: honor funding requests within the allowance const tick = () => mgr.sync().catch(() => {}).then(() => driveExits(mgr)).catch(() => {}); tick(); // Receives arrive in real time over the mailbox stream; the poll is the @@ -457,6 +459,50 @@ export function arkFeature(ctx) { : null)); } + // ---- NWC bridge funding (PoC) -------------------------------------------- + // An external NWC wallet service (e.g. coinos) can bridge ark -> lightning: + // a nostr client asks IT to pay_invoice, it asks US (kind 23196, funding + // request) to cover the amount over ark, we auto-pay within a user-set + // allowance and ack (kind 23197). Custody window = seconds in flight. + // PoC config in localStorage 'btc-wallet-arknwc': { bridgePk, budgetSat }. + const NWC_FUND_KIND = 23196; + const NWC_FUND_ACK_KIND = 23197; + let nwcUnsub = null; + let nwcSpent = 0; + + function stopNwcFunding() { + if (nwcUnsub) { try { nwcUnsub(); } catch {} nwcUnsub = null; } + } + + function startNwcFunding(mgr) { + if (nwcUnsub || !wallet.nostrSubscribe || !wallet.nostrPubkey || !wallet.nostrPubkey()) return; + let cfg = null; + try { cfg = JSON.parse(localStorage.getItem('btc-wallet-arknwc') || 'null'); } catch {} + if (!cfg || !cfg.bridgePk || !(cfg.budgetSat > 0)) return; + const seen = new Set(); + nwcUnsub = wallet.nostrSubscribe( + { kinds: [NWC_FUND_KIND], authors: [cfg.bridgePk], '#p': [wallet.nostrPubkey()] }, + async (ev) => { + if (seen.has(ev.id)) return; + seen.add(ev.id); + let req; + try { req = JSON.parse(ev.content); } catch { return; } + const sats = Math.round(req.amountSat || 0); + if (!sats || !req.address || nwcSpent + sats > cfg.budgetSat) return; // over allowance: bridge times out + try { + const actionId = await mgr.send(req.address, sats); + const action = mgr.state.actions.find((a) => a.id === actionId); + if (!action || action.step === 'failed') return; + nwcSpent += sats; + await wallet.nostrPublish({ + kind: NWC_FUND_ACK_KIND, + tags: [['p', cfg.bridgePk], ['e', ev.id]], + content: JSON.stringify({ id: req.id }), + }); + } catch {} + }); + } + // ---- unilateral exit (trustless: no server cooperation) ------------------ const addrScript = (address) => btc.OutScript.encode(btc.Address(wallet.netCfg.net).decode(address)); diff --git a/src/features/sync.js b/src/features/sync.js index 8349b5d..9e77664 100644 --- a/src/features/sync.js +++ b/src/features/sync.js @@ -37,6 +37,12 @@ export function installSyncWallet(wallet) { this.nostr.setRelays(sync.relays); return this.nostr.fetchEvents(filter, maxWait); }, + nostrSubscribe(filter, onEvent) { + const sync = getSyncConfig(); + if (this.offline || !sync.enabled) return () => {}; + this.nostr.setRelays(sync.relays); + return this.nostr.subscribeEvents(filter, onEvent); + }, // Pull the latest state from Nostr; apply it if it's newer than what we have. // Returns true if state was applied (so the caller can skip a full scan). diff --git a/src/nostr.js b/src/nostr.js index d0df2fb..431bb1e 100644 --- a/src/nostr.js +++ b/src/nostr.js @@ -178,6 +178,14 @@ export class NostrSync { try { return await pool.querySync(this.relays, filter, { maxWait }); } catch { return []; } } + // Live subscription; returns an unsubscribe function. + subscribeEvents(filter, onEvent) { + try { + const sub = pool.subscribeMany(this.relays, [filter], { onevent: onEvent }); + return () => { try { sub.close(); } catch {} }; + } catch { return () => {}; } + } + // Fetch the newest decrypted state across relays, or null. async fetch() { if (!this.sk) return null; -- cgit v1.2.3