diff options
| author | Adam Soltys <[email protected]> | 2026-08-01 09:25:23 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-08-01 09:25:23 -0700 |
| commit | e09b76ea0ed13840426a56f031769f8d4154f34b (patch) | |
| tree | 8a1bdad05d4ab65f795673ba96c532abc0fd5944 | |
| parent | 4f7cfb911a12c2447a9cd18a83f0e806912db964 (diff) | |
| download | coinosv3-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.js | 47 | ||||
| -rw-r--r-- | src/features/index.js | 3 | ||||
| -rw-r--r-- | src/features/nwc.js | 335 | ||||
| -rw-r--r-- | src/features/sync.js | 7 | ||||
| -rw-r--r-- | src/i18n.js | 13 | ||||
| -rw-r--r-- | src/nostr.js | 13 | ||||
| -rw-r--r-- | tools/nwc-test.js | 112 |
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); |
