aboutsummaryrefslogtreecommitdiffstats
path: root/src/features
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-07-06 05:37:42 -0700
committerAdam Soltys <[email protected]>2026-07-06 05:37:42 -0700
commit1139c6beafb1199c149bd1f51a46d817e1d46173 (patch)
tree19dc40ddeb826677f35b20ad8adfc27d5c627793 /src/features
parent8e2ff4976db3d7586d7b9c993a94cf9bea071a12 (diff)
downloadcoinosv3-1139c6beafb1199c149bd1f51a46d817e1d46173.tar.gz
coinosv3-1139c6beafb1199c149bd1f51a46d817e1d46173.zip
NWC bridge funding (PoC): ark wallets behind a lightning facade
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 <[email protected]>
Diffstat (limited to 'src/features')
-rw-r--r--src/features/ark.js46
-rw-r--r--src/features/sync.js6
2 files changed, 52 insertions, 0 deletions
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).