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 /src/features/nwc.js | |
| 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]>
Diffstat (limited to 'src/features/nwc.js')
| -rw-r--r-- | src/features/nwc.js | 335 |
1 files changed, 335 insertions, 0 deletions
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()]; }, + }; +} |
