aboutsummaryrefslogtreecommitdiffstats
path: root/src/features
diff options
context:
space:
mode:
Diffstat (limited to 'src/features')
-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
4 files changed, 391 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 [];