aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-01 09:25:23 -0700
committerAdam Soltys <[email protected]>2026-08-01 09:25:23 -0700
commite09b76ea0ed13840426a56f031769f8d4154f34b (patch)
tree8a1bdad05d4ab65f795673ba96c532abc0fd5944
parent4f7cfb911a12c2447a9cd18a83f0e806912db964 (diff)
downloadcoinosv3-e09b76ea0ed13840426a56f031769f8d4154f34b.tar.gz
coinosv3-e09b76ea0ed13840426a56f031769f8d4154f34b.zip
Add Nostr Wallet Connect (NIP-47) — hal as a wallet service
Other apps can connect over nostr and pay from this wallet without touching its keys, within a per-payment and daily budget the user sets, revocable at any time. hal holds no native Lightning balance, so every operation runs over Ark: pay_invoice takes the swap bridge when configured and falls back to the ASP's lightning-send, make_invoice mints on the ASP. NWC availability therefore tracks Ark availability. - kinds 13194/23194/23195, nip44_v2 and nip04, replying in whichever scheme the request used; every request gets a reply so clients never hang - each connection gets its own service keypair, so revoking one can't affect the others and none is tied to the user's nostr identity - budgets are checked BEFORE spending, not reconciled after - requests are ignored unless signed by that connection's client key, and replayed/stale events (>120s) can't re-trigger a payment - Settings card to create, show (URI + QR) and revoke connections, and it says plainly that this only works while a tab is open tools/nwc-test.js drives it as a real client: both encryption schemes, budget refusal with a genuine over-limit invoice, unsupported-method errors, and a request from an unauthorized key being ignored. Co-Authored-By: Claude Fable 5 <[email protected]>
-rw-r--r--src/features/ark.js47
-rw-r--r--src/features/index.js3
-rw-r--r--src/features/nwc.js335
-rw-r--r--src/features/sync.js7
-rw-r--r--src/i18n.js13
-rw-r--r--src/nostr.js13
-rw-r--r--tools/nwc-test.js112
7 files changed, 529 insertions, 1 deletions
diff --git a/src/features/ark.js b/src/features/ark.js
index 56ffc4c..18baef6 100644
--- a/src/features/ark.js
+++ b/src/features/ark.js
@@ -543,6 +543,43 @@ export function arkFeature(ctx) {
for (const rec of open) await tryBridgeRefund(rec).catch(() => {});
}
+ // Pay a bolt11 with no UI at all — used by the NWC wallet service. Same
+ // routing as the interactive path: the cheaper bridge first, the ASP's own
+ // lightning-send as fallback. Resolves { preimage, feeSat, amountSat }.
+ async function payInvoiceHeadless(invoice, { maxAmountSat } = {}) {
+ const dec = maybeBolt11(invoice);
+ if (!dec) throw new Error('not a bolt11 invoice');
+ if (!dec.amountSat) throw new Error('zero-amount invoices are not supported');
+ if (maxAmountSat && dec.amountSat > maxAmountSat) {
+ throw new Error(`amount ${dec.amountSat} exceeds the limit of ${maxAmountSat} sat`);
+ }
+ const mgr = await connectArk();
+
+ // 1. the bridge, when one is configured and can take it
+ try {
+ const q = await bridgeQuote(invoice);
+ if (q && mgr.vtxos().some((v) => v.state === 'spendable' && v.amountSat >= q.totalSat)) {
+ const res = await bridgePay(q, invoice);
+ return { preimage: res.preimage, feeSat: q.feeSat, amountSat: q.amountSat };
+ }
+ } catch (e) {
+ console.warn('nwc: bridge pay failed, falling back to the ASP:', e.message);
+ }
+
+ // 2. the ASP's native lightning-send
+ const id = await mgr.payLnInvoice(invoice);
+ for (let i = 0; i < 60; i++) {
+ const a = mgr.lnAction(id);
+ if (!a || a.step === 'done') break;
+ if (a.step === 'failed') throw new Error(a.error || 'payment failed');
+ await new Promise((r) => setTimeout(r, 2000));
+ await mgr.driveLn(id).catch(() => {});
+ }
+ const a = mgr.lnAction(id);
+ if (!a || a.step !== 'done') throw new Error(a?.error || a?.lastError || 'payment did not complete');
+ return { preimage: a.preimage, feeSat: a.feeSat, amountSat: a.amountSat };
+ }
+
// A bolt11 lands in Send (via the swaps feature's delegation hook, or
// directly when swaps is absent): take over when the ark balance can
// plausibly cover it, else decline so the Boltz path handles it.
@@ -1614,6 +1651,16 @@ export function arkFeature(ctx) {
// swaps feature's delegation (its matcher runs first) or directly in
// builds without the swaps feature.
startArkLnPay(invoice, meta) { return startArkLnPay(invoice, meta); },
+ // ---- headless seam for the NWC wallet service ----
+ arkPayInvoice(invoice, opts) { return payInvoiceHeadless(invoice, opts); },
+ arkSpendableSat() { const b = arkBalance(); return b ? b.spendableSat : 0; },
+ arkReady() { return arkAvailable(); },
+ async arkMakeInvoice(amountSat, description) {
+ const mgr = await connectArk();
+ const a = await mgr.createLnInvoice(amountSat, description);
+ return { invoice: a.invoice, paymentHash: a.paymentHash, amountSat };
+ },
+ arkMovements() { const s = arkStateNow(); return s ? (s.movements || []) : []; },
// 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
// only in builds without the swaps feature (whose matcher runs first and
diff --git a/src/features/index.js b/src/features/index.js
index e52b172..06d8d4d 100644
--- a/src/features/index.js
+++ b/src/features/index.js
@@ -10,11 +10,12 @@ import { arkFeature } from './ark.js';
import { zapsFeature } from './zaps.js';
import { spFeature } from './sp.js';
import { syncFeature } from './sync.js';
+import { nwcFeature } from './nwc.js';
// NB order is meaningful where hooks stack: gifts' receive takeover and
// balance line come before ark's, matching the pre-plugin layout. zaps sits
// after ark so an npub prefers an instant Ark zap and only falls through to a
// Lightning zap when Ark can't serve it.
export function buildFeatures(ctx) {
- return [giftsFeature(ctx), swapsFeature(ctx), arkFeature(ctx), zapsFeature(ctx), spFeature(ctx), syncFeature(ctx)];
+ return [giftsFeature(ctx), swapsFeature(ctx), arkFeature(ctx), zapsFeature(ctx), spFeature(ctx), nwcFeature(ctx), syncFeature(ctx)];
}
diff --git a/src/features/nwc.js b/src/features/nwc.js
new file mode 100644
index 0000000..b582ed9
--- /dev/null
+++ b/src/features/nwc.js
@@ -0,0 +1,335 @@
+// Nostr Wallet Connect (NIP-47) — hal as a *wallet service*.
+//
+// Other apps (Damus, Amethyst, Alby, a website) connect to your hal wallet
+// over nostr and can ask it to pay invoices or report a balance, without ever
+// touching your keys. You hand out a connection string; hal answers requests
+// signed by that connection, inside the budget you set, and you can revoke it
+// at any time.
+//
+// hal holds no native Lightning balance, so every Lightning operation here
+// runs over Ark: paying goes through the swap bridge when one is configured
+// and falls back to the ASP's own lightning-send, and receiving mints an
+// invoice on the ASP. That means NWC availability tracks Ark availability —
+// no Ark server, no wallet service.
+//
+// Shape of the protocol (mirrors coinos-server/lib/nwc.ts):
+// kind 13194 info event, content = space-separated supported methods
+// kind 23194 request (client -> wallet), encrypted to the wallet key
+// kind 23195 response (wallet -> client), same encryption scheme back
+// Encryption is nip04 unless the request carries `["encryption","nip44_v2"]`.
+//
+// IMPORTANT limitation, surfaced in the UI: hal is a web wallet, so it only
+// answers while a tab is open. It is not an always-on wallet service.
+
+import { hex } from '@scure/base';
+import { sha256 } from '@noble/hashes/sha256';
+import {
+ nip04, nip44, getPublicKey, finalizeEvent, generateSecretKey,
+} from '../nostr.js';
+import { t } from '../i18n.js';
+import { qrSvg } from '../qr.js';
+import { getNetwork } from '../api.js';
+import { maybeBolt11 } from '../ark/lightning.js';
+
+const REQ_KIND = 23194;
+const RES_KIND = 23195;
+const INFO_KIND = 13194;
+
+// What we can actually honour. No pay_keysend (Ark can't), no multi_pay.
+const METHODS = ['get_info', 'get_balance', 'pay_invoice', 'make_invoice', 'list_transactions'];
+
+// Requests older than this are ignored — a relay replaying history must not
+// re-trigger payments.
+const MAX_AGE_SEC = 120;
+
+const nowSec = () => Math.floor(Date.now() / 1000);
+const errRes = (code, message) => ({ error: { code, message } });
+
+export function nwcFeature(ctx) {
+ const { h, ui, render, wallet, hook, fmtAmount, unitLabel, copyBtn, toast } = ctx;
+
+ // ---- persisted connections -------------------------------------------
+ // { id, name, secret (client sk hex), clientPk, servicePk, serviceSk,
+ // maxSat, dailySat, spentToday, spentDate, created, lastUsed, revoked }
+ const load = () => wallet.loadFeatureState('nwc', { conns: [] });
+ const save = (st) => wallet.saveFeatureState('nwc', st);
+ const conns = () => (load().conns || []).filter((c) => !c.revoked);
+
+ function updateConn(id, patch) {
+ const st = load();
+ const c = (st.conns || []).find((x) => x.id === id);
+ if (!c) return null;
+ Object.assign(c, patch);
+ save(st);
+ return c;
+ }
+
+ // Budget accounting, reset on date change.
+ function spendRoom(c) {
+ const today = new Date().toISOString().slice(0, 10);
+ const spent = c.spentDate === today ? (c.spentToday || 0) : 0;
+ return { today, spent, left: Math.max(0, (c.dailySat || 0) - spent) };
+ }
+ function recordSpend(c, sat) {
+ const { today, spent } = spendRoom(c);
+ updateConn(c.id, { spentDate: today, spentToday: spent + sat, lastUsed: Date.now() });
+ }
+
+ function relays() {
+ const r = wallet.nostrRelays ? wallet.nostrRelays() : [];
+ return r.length ? r.slice(0, 4) : ['wss://relay.coinos.io'];
+ }
+
+ // The string the user pastes into another app.
+ function uriFor(c) {
+ const rs = relays().map((r) => `relay=${encodeURIComponent(r)}`).join('&');
+ return `nostr+walletconnect://${c.servicePk}?${rs}&secret=${c.secret}`;
+ }
+
+ function createConn({ name, maxSat, dailySat }) {
+ const st = load();
+ st.conns = st.conns || [];
+ // A dedicated service key per connection: revoking one can't affect the
+ // others, and none of them is the user's own nostr identity.
+ const serviceSk = generateSecretKey();
+ const clientSk = generateSecretKey();
+ const c = {
+ id: hex.encode(sha256(new Uint8Array([...serviceSk, ...clientSk]))).slice(0, 12),
+ name: name || 'App',
+ serviceSk: hex.encode(serviceSk),
+ servicePk: getPublicKey(serviceSk),
+ secret: hex.encode(clientSk),
+ clientPk: getPublicKey(clientSk),
+ maxSat: maxSat || 10000,
+ dailySat: dailySat || 50000,
+ spentToday: 0,
+ spentDate: null,
+ created: Date.now(),
+ revoked: false,
+ };
+ st.conns.push(c);
+ save(st);
+ publishInfo(c).catch(() => {});
+ listen();
+ return c;
+ }
+
+ // ---- protocol ---------------------------------------------------------
+
+ async function publishInfo(c) {
+ const sk = hex.decode(c.serviceSk);
+ const evt = finalizeEvent({
+ kind: INFO_KIND,
+ created_at: nowSec(),
+ tags: [['p', c.clientPk], ['encryption', 'nip44_v2 nip04']],
+ content: METHODS.join(' '),
+ }, sk);
+ return wallet.nostrPublishSigned ? wallet.nostrPublishSigned(evt) : null;
+ }
+
+ const decrypt = (scheme, sk, pk, ct) => (scheme === 'nip44_v2'
+ ? nip44.decrypt(ct, nip44.getConversationKey(sk, pk))
+ : nip04.decrypt(sk, pk, ct));
+ const encrypt = (scheme, sk, pk, txt) => (scheme === 'nip44_v2'
+ ? nip44.encrypt(txt, nip44.getConversationKey(sk, pk))
+ : nip04.encrypt(sk, pk, txt));
+
+ // Every request gets a reply, including failures — a client that never
+ // hears back just hangs.
+ async function reply(c, ev, scheme, payload) {
+ const sk = hex.decode(c.serviceSk);
+ try {
+ const content = await encrypt(scheme, sk, ev.pubkey, JSON.stringify(payload));
+ const evt = finalizeEvent({
+ kind: RES_KIND,
+ created_at: nowSec(),
+ tags: [['p', ev.pubkey], ['e', ev.id], ['encryption', scheme]],
+ content,
+ }, sk);
+ await wallet.nostrPublishSigned(evt);
+ } catch (e) {
+ console.warn('nwc: could not reply', e.message);
+ }
+ }
+
+ const handled = new Set(); // request ids seen this session (replay guard)
+
+ async function onRequest(c, ev) {
+ if (ev.kind !== REQ_KIND) return;
+ if (handled.has(ev.id)) return;
+ handled.add(ev.id);
+ if (handled.size > 500) handled.clear();
+ if (ev.created_at && nowSec() - ev.created_at > MAX_AGE_SEC) return;
+ // only the client this connection was issued to
+ if (ev.pubkey !== c.clientPk) return;
+
+ const scheme = ev.tags?.find((x) => x[0] === 'encryption')?.[1] === 'nip44_v2'
+ ? 'nip44_v2' : 'nip04';
+ let req;
+ try {
+ req = JSON.parse(await decrypt(scheme, hex.decode(c.serviceSk), ev.pubkey, ev.content));
+ } catch { return; } // undecryptable: not for us
+ const { method, params = {} } = req || {};
+ if (!METHODS.includes(method)) {
+ return reply(c, ev, scheme, { result_type: method, ...errRes('NOT_IMPLEMENTED', 'method not supported') });
+ }
+ updateConn(c.id, { lastUsed: Date.now() });
+
+ try {
+ const out = await handle(c, method, params);
+ await reply(c, ev, scheme, { result_type: method, ...out });
+ } catch (e) {
+ await reply(c, ev, scheme, { result_type: method, ...errRes('INTERNAL', e.message || 'failed') });
+ }
+ render();
+ }
+
+ async function handle(c, method, params) {
+ if (!hook('arkReady')) return errRes('UNAUTHORIZED', 'Ark is not configured in this wallet');
+
+ if (method === 'get_info') {
+ return {
+ result: {
+ alias: 'Hal', color: '#f7931a', network: getNetwork() === 'mainnet' ? 'mainnet' : getNetwork(),
+ block_height: 0, block_hash: '', methods: METHODS,
+ },
+ };
+ }
+
+ if (method === 'get_balance') {
+ // NIP-47 reports millisats
+ return { result: { balance: (hook('arkSpendableSat') || 0) * 1000 } };
+ }
+
+ if (method === 'pay_invoice') {
+ const invoice = params.invoice;
+ if (!invoice) return errRes('OTHER', 'missing invoice');
+ // Enforce limits BEFORE spending anything — an over-budget request must
+ // be refused, not paid and then regretted.
+ const dec = maybeBolt11(invoice);
+ if (!dec) return errRes('OTHER', 'not a bolt11 invoice');
+ if (!dec.amountSat) return errRes('OTHER', 'zero-amount invoices are not supported');
+ const { left } = spendRoom(c);
+ if (dec.amountSat > c.maxSat) {
+ return errRes('QUOTA_EXCEEDED', `over the ${c.maxSat} sat per-payment limit`);
+ }
+ if (dec.amountSat > left) {
+ return errRes('QUOTA_EXCEEDED', `over the remaining daily budget (${left} sat)`);
+ }
+ const res = await hook('arkPayInvoice', invoice, { maxAmountSat: c.maxSat });
+ // the ark seam throws on failure, so reaching here means it settled
+ recordSpend(c, (res.amountSat || 0) + (res.feeSat || 0));
+ return { result: { preimage: res.preimage, fees_paid: (res.feeSat || 0) * 1000 } };
+ }
+
+ if (method === 'make_invoice') {
+ const sat = Math.floor((params.amount || 0) / 1000);
+ if (!sat) return errRes('OTHER', 'amount required (msat)');
+ const inv = await hook('arkMakeInvoice', sat, params.description || '');
+ return {
+ result: {
+ type: 'incoming', invoice: inv.invoice, payment_hash: inv.paymentHash,
+ amount: sat * 1000, created_at: nowSec(), description: params.description || '',
+ },
+ };
+ }
+
+ if (method === 'list_transactions') {
+ const movements = hook('arkMovements') || [];
+ const txs = movements
+ .filter((m) => ['ln-send', 'ln-receive'].includes(m.type) && m.status === 'complete')
+ .slice(-(params.limit || 20))
+ .map((m) => ({
+ type: m.type === 'ln-receive' ? 'incoming' : 'outgoing',
+ invoice: m.invoice || '', preimage: m.preimage || '',
+ amount: (m.amountSat || 0) * 1000, fees_paid: 0,
+ created_at: Math.floor((m.ts || Date.now()) / 1000),
+ settled_at: Math.floor((m.ts || Date.now()) / 1000),
+ description: m.detail || '',
+ }));
+ return { result: { transactions: txs } };
+ }
+
+ return errRes('NOT_IMPLEMENTED', 'method not supported');
+ }
+
+ // ---- lifecycle --------------------------------------------------------
+
+ let unsubs = [];
+ function stop() {
+ for (const u of unsubs) { try { u(); } catch {} }
+ unsubs = [];
+ }
+ function listen() {
+ stop();
+ if (!wallet.nostrSubscribe || wallet.watchOnly) return;
+ const list = conns();
+ if (!list.length) return;
+ for (const c of list) {
+ const un = wallet.nostrSubscribe(
+ { kinds: [REQ_KIND], '#p': [c.servicePk], since: nowSec() - MAX_AGE_SEC },
+ (ev) => { onRequest(c, ev).catch(() => {}); },
+ );
+ unsubs.push(un);
+ }
+ }
+
+ // ---- UI ---------------------------------------------------------------
+
+ function nwcCard() {
+ if (!hook('arkReady')) return null;
+ const list = conns();
+ const st = ui.nwcNew;
+ return h('div', { class: 'card col' },
+ h('h3', { class: 'row gap6', style: 'align-items:center' }, '🔌', t('nwcTitle')),
+ h('p', { class: 'small muted', style: 'margin:0' }, t('nwcDesc')),
+ h('div', { class: 'small faint' }, t('nwcTabWarning')),
+ ...list.map((c) => {
+ const { spent } = spendRoom(c);
+ return h('div', { class: 'col', style: 'gap:4px;border-top:1px solid var(--border,rgba(128,128,128,.2));padding-top:8px' },
+ h('div', { class: 'row between' },
+ h('span', {}, c.name),
+ h('span', { class: 'linklike small', onClick: () => {
+ updateConn(c.id, { revoked: true }); listen(); render();
+ toast(t('nwcRevoked', { name: c.name }));
+ } }, t('nwcRevoke'))),
+ h('div', { class: 'small faint' },
+ t('nwcLimits', { max: fmtAmount(c.maxSat), day: fmtAmount(c.dailySat), spent: fmtAmount(spent) })),
+ ui.nwcShow === c.id
+ ? h('div', { class: 'col', style: 'gap:6px;align-items:center' },
+ h('div', { html: qrSvg(uriFor(c)) }),
+ h('div', { class: 'addr-box break', style: 'width:100%;font-size:10px' }, uriFor(c)),
+ copyBtn(uriFor(c), t('nwcCopy')))
+ : h('button', { class: 'btn-sm', onClick: () => { ui.nwcShow = c.id; render(); } }, t('nwcShow')));
+ }),
+ 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,
+ onInput: (e) => { st.name = e.target.value; } }),
+ h('label', { class: 'field' }, h('span', { class: 'lab' }, t('nwcMaxPayment')),
+ h('input', { type: 'number', min: '1', value: st.maxSat,
+ onInput: (e) => { st.maxSat = e.target.value; } })),
+ h('label', { class: 'field' }, h('span', { class: 'lab' }, t('nwcDailyBudget')),
+ h('input', { type: 'number', min: '1', value: st.dailySat,
+ onInput: (e) => { st.dailySat = e.target.value; } })),
+ h('div', { class: 'row gap6' },
+ h('button', { class: 'btn-ghost', onClick: () => { ui.nwcNew = null; render(); } }, t('cancel')),
+ h('button', { class: 'btn-primary grow', onClick: () => {
+ const c = createConn({
+ name: st.name, maxSat: parseInt(st.maxSat, 10) || 10000,
+ dailySat: parseInt(st.dailySat, 10) || 50000,
+ });
+ ui.nwcNew = null; ui.nwcShow = c.id; render();
+ } }, t('nwcCreate'))))
+ : h('button', { class: 'btn-ghost btn-block', onClick: () => {
+ ui.nwcNew = { name: '', maxSat: '10000', dailySat: '50000' }; render();
+ } }, t('nwcAdd')));
+ }
+
+ return {
+ id: 'nwc',
+ init() { listen(); },
+ stop() { stop(); },
+ settingsCards() { return [nwcCard()]; },
+ };
+}
diff --git a/src/features/sync.js b/src/features/sync.js
index 1e2db41..76277a7 100644
--- a/src/features/sync.js
+++ b/src/features/sync.js
@@ -49,6 +49,13 @@ export function installSyncWallet(wallet) {
this.nostr.setRelays(sync.relays);
return this.nostr.publishEvent(partial);
},
+ // Publish an event signed by another key (the NWC wallet service keys).
+ async nostrPublishSigned(evt) {
+ const sync = getSyncConfig();
+ if (this.offline || !sync.enabled) return null;
+ this.nostr.setRelays(sync.relays);
+ return this.nostr.publishSigned(evt);
+ },
async nostrFetch(filter, maxWait) {
const sync = getSyncConfig();
if (this.offline || !sync.enabled) return [];
diff --git a/src/i18n.js b/src/i18n.js
index 91fef4b..b9371e7 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -435,6 +435,19 @@ const STR = {
arkLnPayFailed: 'Payment failed',
arkLnRefunded: 'Payment failed — funds returned to your Ark balance.',
arkLnPaidTitle: 'Paid!',
+ nwcTitle: 'Nostr Wallet Connect',
+ 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',
+ nwcAppName: 'App name (e.g. Damus)',
+ nwcMaxPayment: 'Max per payment (sats)',
+ nwcDailyBudget: 'Daily budget (sats)',
+ nwcCreate: 'Create connection',
+ nwcShow: 'Show connection string',
+ nwcCopy: 'Copy connection string',
+ nwcRevoke: 'Revoke',
+ nwcRevoked: 'Revoked {name}',
+ nwcLimits: 'Max {max}/payment · {day}/day · {spent} spent today',
arkBridgeTitle: 'Lightning swap bridge',
arkBridgeDesc: 'Optional third-party service that pays Lightning invoices from your Ark balance, usually cheaper than the Ark server itself. Trustless: your sats are locked in a hash contract the bridge can only take by actually paying your invoice.',
arkBridgeUrl: 'Bridge URL',
diff --git a/src/nostr.js b/src/nostr.js
index 407663c..652c579 100644
--- a/src/nostr.js
+++ b/src/nostr.js
@@ -11,6 +11,7 @@
import * as nip06 from 'nostr-tools/nip06';
import * as nip44 from 'nostr-tools/nip44';
+import * as nip04 from 'nostr-tools/nip04';
import { wrapEvent as nip17WrapEvent } from 'nostr-tools/nip17';
import { getPublicKey, finalizeEvent, generateSecretKey } from 'nostr-tools/pure';
import { decode as nip19decode, npubEncode } from 'nostr-tools/nip19';
@@ -24,6 +25,11 @@ const pool = new SimplePool();
// --- locking a gift to a recipient's Nostr key ---------------------------
// An npub or 64-hex nostr pubkey → hex, or null if it isn't one.
+// NIP-47 wallet services sign with their own per-connection key and must
+// speak whichever encryption the client asked for, so export both schemes.
+export { nip04, nip44 };
+export { getPublicKey, finalizeEvent, generateSecretKey };
+
export function parseNostrPubkey(input) {
const s = (input || '').trim();
if (/^[0-9a-f]{64}$/i.test(s)) return s.toLowerCase();
@@ -212,6 +218,13 @@ export class NostrSync {
} catch { return null; }
}
+ // Publish an event signed by some other key (the NWC service key). The
+ // wallet's own key must not sign wallet-service traffic — connections are
+ // revocable and shouldn't be tied to the user's nostr identity.
+ async publishSigned(evt, relays = this.relays) {
+ try { await Promise.allSettled(pool.publish(relays, evt)); return true; } catch { return false; }
+ }
+
// Fetch events matching a filter from the relays (best-effort).
async fetchEvents(filter, maxWait = 5000) {
try { return await pool.querySync(this.relays, filter, { maxWait }); } catch { return []; }
diff --git a/tools/nwc-test.js b/tools/nwc-test.js
new file mode 100644
index 0000000..e22c5d2
--- /dev/null
+++ b/tools/nwc-test.js
@@ -0,0 +1,112 @@
+// Drive the NWC feature as a real NIP-47 client would:
+// bun tools/nwc-test.js
+// encrypted 23194
+// requests in, encrypted 23195 responses out, both nip04 and nip44.
+import { hex } from '@scure/base';
+import { nwcFeature } from '/home/adam/halwallet/src/features/nwc.js';
+import { nip04, nip44, getPublicKey, finalizeEvent, generateSecretKey } from '/home/adam/halwallet/src/nostr.js';
+
+let ok = true;
+const check = (n, c, d='') => { console.log(` ${c?'✓':'✗'} ${n}${d?' — '+d:''}`); if(!c) ok=false; };
+
+const store = {};
+globalThis.localStorage = { getItem:k=>k in store?store[k]:null, setItem:(k,v)=>{store[k]=v;}, removeItem:k=>{delete store[k];} };
+
+let subHandler = null;
+const published = [];
+let paid = [];
+const wallet = {
+ watchOnly: false,
+ loadFeatureState: (n, d) => (store['fs:'+n] ? JSON.parse(store['fs:'+n]) : d),
+ saveFeatureState: (n, v) => { store['fs:'+n] = JSON.stringify(v); },
+ nostrRelays: () => ['wss://relay.example'],
+ nostrSubscribe: (filter, on) => { subHandler = on; return () => { subHandler = null; }; },
+ nostrPublishSigned: async (evt) => { published.push(evt); return true; },
+};
+const hooks = {
+ arkReady: () => true,
+ arkSpendableSat: () => 42000,
+ arkPayInvoice: async (inv, opts) => { paid.push({ inv, opts }); return { preimage: 'ab'.repeat(32), feeSat: 1, amountSat: 25 }; },
+ arkMakeInvoice: async (sat) => ({ invoice: 'lnbc'+sat, paymentHash: 'cd'.repeat(32), amountSat: sat }),
+ arkMovements: () => ([{ type:'ln-send', status:'complete', amountSat: 25, ts: Date.now(), invoice:'lnbc250n1', preimage:'ef'.repeat(32) }]),
+};
+const ctx = { h:()=>null, ui:{}, render:()=>{}, wallet, hook:(n,...a)=>hooks[n]?hooks[n](...a):null,
+ fmtAmount:(n)=>String(n), unitLabel:()=>'sats', copyBtn:()=>null, toast:()=>{} };
+
+const f = nwcFeature(ctx);
+f.init();
+
+// create a connection the way the UI does
+const st = { conns: [] };
+// invoke via the settings card is awkward headless; call through the module's
+// exported behaviour by simulating what createConn does: use the feature's card
+// action indirectly — instead drive persistence directly then re-init.
+const serviceSk = generateSecretKey(), clientSk = generateSecretKey();
+const conn = {
+ id: 'test1', name: 'TestApp',
+ serviceSk: hex.encode(serviceSk), servicePk: getPublicKey(serviceSk),
+ secret: hex.encode(clientSk), clientPk: getPublicKey(clientSk),
+ maxSat: 1000, dailySat: 2000, spentToday: 0, spentDate: null, created: Date.now(), revoked: false,
+};
+store['fs:nwc'] = JSON.stringify({ conns: [conn] });
+f.stop(); f.init();
+check('subscribed for requests', typeof subHandler === 'function');
+
+async function request(method, params, scheme='nip44_v2') {
+ const payload = JSON.stringify({ method, params });
+ const content = scheme === 'nip44_v2'
+ ? nip44.encrypt(payload, nip44.getConversationKey(clientSk, conn.servicePk))
+ : await nip04.encrypt(clientSk, conn.servicePk, payload);
+ const ev = finalizeEvent({ kind: 23194, created_at: Math.floor(Date.now()/1000),
+ tags: [['p', conn.servicePk], ...(scheme==='nip44_v2'?[['encryption','nip44_v2']]:[])], content }, clientSk);
+ const before = published.length;
+ await subHandler(ev);
+ for (let i=0;i<40 && published.length===before;i++) await new Promise(r=>setTimeout(r,50));
+ const res = published[published.length-1];
+ if (!res) return null;
+ const txt = scheme === 'nip44_v2'
+ ? nip44.decrypt(res.content, nip44.getConversationKey(clientSk, conn.servicePk))
+ : await nip04.decrypt(clientSk, conn.servicePk, res.content);
+ return JSON.parse(txt);
+}
+
+console.log('\n[nip44 requests]');
+let r = await request('get_info', {});
+check('get_info lists methods', r?.result?.methods?.includes('pay_invoice'), JSON.stringify(r?.result?.methods));
+r = await request('get_balance', {});
+check('get_balance in msat', r?.result?.balance === 42000*1000, String(r?.result?.balance));
+r = await request('pay_invoice', { invoice: 'lnbc250n1p4xu9wtsp5t5c9w2qlv4mkngwn6txm4' });
+check('pay_invoice rejects an unparseable invoice', !!r?.error, JSON.stringify(r?.error));
+
+console.log('\n[budget enforcement]');
+// a real-looking 25-sat invoice from earlier in this session
+const INV25 = 'lnbc250n1p4xu9wtsp5t5c9w2qlv4mkngwn6txm4';
+r = await request('pay_invoice', { invoice: 'lnbc50u1p4xu8whsp5jjnp3jzhjrgp3992dhurhryued5dhk0fvp7jexkjtd86czhwu77spp5dg2d76m6cjl5s6s6m2sffeux746ha345cmmmu2ttgjs0mu2gnkfsdqcdemkxgrzw4jxwet5yp6x2um5xqyjw5qcqpjrzjqgfffll4jmjf0tffqtx47xt886gzp9fajp3966xz96gm2xj9cqedxr4zxyqq3vgqqqqqqqqqqqqqrssqyg9qxpqysgq0g76kefudlp6z9qp3xypghqvqtkgtz3cwc89f76x4wzkvnx2lmu9azt8ewnet7avrhxek62sdm67zx72mnntldarhlsdkcna9k9r4uqqe5lr3p' }); // 5000 sat, over the 1000 limit
+check('over-limit payment refused', r?.error?.code === 'QUOTA_EXCEEDED', JSON.stringify(r?.error));
+check('nothing was paid', paid.length === 0);
+
+console.log('\n[nip04 fallback]');
+r = await request('get_balance', {}, 'nip04');
+check('nip04 request answered', r?.result?.balance === 42000*1000);
+
+console.log('\n[other methods]');
+r = await request('make_invoice', { amount: 21000 });
+check('make_invoice returns a bolt11', !!r?.result?.invoice, r?.result?.invoice);
+r = await request('list_transactions', {});
+check('list_transactions returns history', Array.isArray(r?.result?.transactions) && r.result.transactions.length === 1);
+r = await request('pay_keysend', {});
+check('unsupported method errors cleanly', r?.error?.code === 'NOT_IMPLEMENTED');
+
+console.log('\n[isolation]');
+const strangerSk = generateSecretKey();
+const payload = JSON.stringify({ method: 'get_balance', params: {} });
+const ev = finalizeEvent({ kind: 23194, created_at: Math.floor(Date.now()/1000),
+ tags: [['p', conn.servicePk], ['encryption','nip44_v2']],
+ content: nip44.encrypt(payload, nip44.getConversationKey(strangerSk, conn.servicePk)) }, strangerSk);
+const before = published.length;
+await subHandler(ev);
+await new Promise(r=>setTimeout(r,300));
+check('request from an unauthorized key is ignored', published.length === before);
+
+console.log(ok ? '\n✅ NWC service behaves' : '\n❌ failed');
+process.exit(ok?0:1);