From ed1d996f9dbd6cedc18ec293daba0cfcd745ae2b Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sat, 1 Aug 2026 20:46:12 -0700 Subject: Zappable offer codes and per-name Lightning offers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLINK offers (noffer): every wallet gets a static offer keypair (synced across devices, first-minted wins) answering kind-21001 invoice requests — from open tabs and from the service worker while closed, which mints against the mirrored state (the record now carries chain-4 and chain-5 key windows) and raises an open-to-receive notification. Zap requests ride along and their kind-9735 receipts publish once the payment is claimed. The code is shown in the payment-address card for pasting into a Nostr profile; the notifier watches and relays kind 21001 alongside NWC. Payment names also gain a Lightning offer: the registrar creates a BOLT 12 offer per name on the node and appends lno= to the DNS record; a settle-forwarder pushes each received payment on to the name's ark destination from a small float wallet (custody window: seconds between Lightning settle and the ark send). Name resolution now runs full client-side DNSSEC validation (src/dnssec.js): binary DoH, root-anchor chain walk, RSA + ECDSA via WebCrypto — the resolver is untrusted transport, verified against live signed zones including the canonical BIP-353 test name. Co-Authored-By: Claude Fable 5 --- src/bip353.js | 61 +++------ src/dnssec.js | 355 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/features/ark.js | 7 +- src/features/names.js | 10 +- src/features/nwc.js | 148 +++++++++++++++++++-- src/i18n.js | 11 +- src/noffer.js | 55 ++++++++ src/nwc-bg.js | 19 ++- src/nwc-respond.js | 102 +++++++++++++-- src/sw-nwc.js | 11 ++ 10 files changed, 699 insertions(+), 80 deletions(-) create mode 100644 src/dnssec.js create mode 100644 src/noffer.js (limited to 'src') diff --git a/src/bip353.js b/src/bip353.js index 2bacb65..efa1721 100644 --- a/src/bip353.js +++ b/src/bip353.js @@ -1,55 +1,28 @@ -// BIP-353 DNS payment instructions — resolving ₿name@domain to a BIP-21 URI. +// BIP-353 DNS payment instructions — resolving name@domain to a BIP-21 URI. // // The record lives at .user._bitcoin-payment. as a DNSSEC-signed -// TXT containing one `bitcoin:` URI. Browsers can't do raw DNS, so we resolve -// over DNS-over-HTTPS. TRUST LEVEL, stated honestly: full client-side DNSSEC -// validation (what the BIP ultimately wants) is not implemented yet — instead -// we require agreement between TWO independent validating resolvers -// (Cloudflare and Google), each reporting the answer DNSSEC-authenticated -// (AD flag). Collusion or simultaneous compromise of both is the residual -// risk; a native chain validator is the follow-up. +// TXT containing one `bitcoin:` URI. Resolution runs full client-side DNSSEC +// chain validation (src/dnssec.js): the DoH resolver is untrusted transport, +// and every answer we act on is provably signed from the ICANN root down. -const RESOLVERS = [ - 'https://cloudflare-dns.com/dns-query', - 'https://dns.google/resolve', -]; - -// "a" "b" quoted character-strings → concatenated payload -function txtPayload(data) { - const parts = [...String(data).matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((m) => m[1].replace(/\\(.)/g, '$1')); - return parts.length ? parts.join('') : String(data); -} - -async function queryOne(base, label) { - const r = await fetch(`${base}?name=${label}&type=TXT`, { headers: { accept: 'application/dns-json' } }); - if (!r.ok) throw new Error(`resolver ${r.status}`); - const j = await r.json(); - const uris = (j.Answer || []) - .filter((a) => a.type === 16) - .map((a) => txtPayload(a.data)) - .filter((t) => /^bitcoin:/i.test(t)); - // BIP-353: multiple bitcoin: TXT records at one label are invalid - if (uris.length > 1) throw new Error('multiple payment records'); - return { uri: uris[0] || null, ad: !!j.AD }; -} +import { resolveTxtValidated } from './dnssec.js'; // name like "adam", domain like "halwallet.app" → the bitcoin: URI, or null -// when no record exists. Throws when resolution can't be trusted. +// when no record exists. Throws when the answer can't be proven authentic. export async function resolveBip353(name, domain) { const label = `${name}.user._bitcoin-payment.${domain}`; - const results = await Promise.allSettled(RESOLVERS.map((r) => queryOne(r, label))); - const ok = results.filter((r) => r.status === 'fulfilled').map((r) => r.value); - if (ok.length < 2) { - // one resolver down: accept a single authenticated answer, refuse a - // single unauthenticated one - if (ok.length === 1 && ok[0].uri && ok[0].ad) return ok[0].uri; - if (ok.length === 1 && !ok[0].uri) return null; - throw new Error('could not resolve the name'); + let res; + try { + res = await resolveTxtValidated(label); + } catch (e) { + if (/^no TXT record/.test(e.message)) return null; + throw e; } - if (ok[0].uri !== ok[1].uri) throw new Error('resolvers disagree — refusing'); - if (!ok[0].uri) return null; - if (!ok[0].ad || !ok[1].ad) throw new Error('name is not DNSSEC-signed'); - return ok[0].uri; + const uris = res.txts.filter((t) => /^bitcoin:/i.test(t)); + if (!uris.length) return null; + // BIP-353: multiple bitcoin: TXT records at one label are invalid + if (uris.length > 1) throw new Error('multiple payment records — invalid'); + return uris[0]; } // A pasted payment name: optional ₿, user@domain with a real-looking TLD. diff --git a/src/dnssec.js b/src/dnssec.js new file mode 100644 index 0000000..50c6ac9 --- /dev/null +++ b/src/dnssec.js @@ -0,0 +1,355 @@ +// Client-side DNSSEC validation — the trust layer BIP-353 requires. +// +// A payment name maps to money, so the DNS answer must be provably signed by +// the zone owner all the way from the ICANN root, not vouched for by whoever +// answered the HTTP request. This module speaks DoH in *wireformat* (binary +// DNS messages), parses them itself, and walks the chain: +// +// root trust anchor (hardcoded KSK DS digests) +// → root DNSKEY (RRSIG self-signed by the anchored KSK) +// → DS for the TLD (signed in the root zone) +// → TLD DNSKEY … and so on down to the zone holding the record +// → RRSIG over the final TXT/CNAME RRset +// +// The resolver becomes untrusted transport: it can lie only by omission +// (denial), never by forgery. Algorithms: 8 (RSA/SHA-256) — the root and +// most TLDs — and 13 (ECDSA P-256/SHA-256) — most hosted zones. Both verify +// through WebCrypto, so this works in pages, workers, and Bun alike. +// +// Deliberately NOT implemented: NSEC/NSEC3 denial proofs. We never need to +// prove a name does NOT exist — an unsigned or missing chain simply fails +// closed ("not provably signed"), which is the right answer for payments. + +import { sha256 } from '@noble/hashes/sha256'; + +const DOH = 'https://cloudflare-dns.com/dns-query'; +const DOH_FALLBACK = 'https://dns.google/dns-query'; + +// ICANN root KSK trust anchors (DS rdata: keytag, alg, digest type 2 SHA-256). +const ROOT_ANCHORS = [ + { keyTag: 20326, algorithm: 8, digest: 'e06d44b80b8f1d39a95c0b0d7c65d08458e880409bbc683457104237c7f8ec8d' }, + { keyTag: 38696, algorithm: 8, digest: '683d2d0acb8c9b712a1948b27f741219298d0a450d612c483af444a4c0fb2b16' }, +]; + +const TYPE = { A: 1, CNAME: 5, TXT: 16, DS: 43, RRSIG: 46, DNSKEY: 48, OPT: 41 }; +const now = () => Math.floor(Date.now() / 1000); +const CLOCK_SKEW = 3600; + +// --------------------------------------------------------------------------- +// wire encoding/decoding +// --------------------------------------------------------------------------- + +const te = new TextEncoder(); +function nameToWire(name) { + const out = []; + for (const label of name.replace(/\.$/, '').split('.').filter(Boolean)) { + const b = te.encode(label.toLowerCase()); + if (b.length > 63) throw new Error('label too long'); + out.push(b.length, ...b); + } + out.push(0); + return new Uint8Array(out); +} + +function buildQuery(name, qtype) { + const qname = nameToWire(name); + const head = new Uint8Array(12); + // id 0 (required for DoH GET caching), RD=1 + head[2] = 0x01; + const q = new Uint8Array(qname.length + 4); + q.set(qname, 0); + const dv = new DataView(q.buffer); + dv.setUint16(qname.length, qtype); + dv.setUint16(qname.length + 2, 1); // IN + // EDNS OPT with DO=1, 4096-byte payload: + // name(00) type(0029) class=payload(1000) ttl=[ercode 00, ver 00, flags 8000] rdlen(0000) + const opt = new Uint8Array([0x00, 0x00, 0x29, 0x10, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00]); + const msg = new Uint8Array(12 + q.length + opt.length); + msg.set(head, 0); + const mdv = new DataView(msg.buffer); + mdv.setUint16(4, 1); // qdcount + mdv.setUint16(10, 1); // arcount (OPT) + msg.set(q, 12); + msg.set(opt, 12 + q.length); + return msg; +} + +function readName(msg, off) { + const labels = []; + let jumped = false, next = off, guard = 0; + while (guard++ < 200) { + const len = msg[off]; + if (len === 0) { if (!jumped) next = off + 1; break; } + if ((len & 0xc0) === 0xc0) { + const ptr = ((len & 0x3f) << 8) | msg[off + 1]; + if (!jumped) next = off + 2; + jumped = true; + off = ptr; + continue; + } + labels.push(new TextDecoder().decode(msg.slice(off + 1, off + 1 + len))); + off += 1 + len; + } + return { name: labels.join('.').toLowerCase(), next }; +} + +function parseMessage(buf) { + const msg = new Uint8Array(buf); + const dv = new DataView(msg.buffer, msg.byteOffset, msg.byteLength); + const qd = dv.getUint16(4), an = dv.getUint16(6), ns = dv.getUint16(8), ar = dv.getUint16(10); + const rcode = dv.getUint16(2) & 0x0f; + let off = 12; + for (let i = 0; i < qd; i++) { off = readName(msg, off).next + 4; } + const rrs = []; + const total = an + ns + ar; + for (let i = 0; i < total; i++) { + const { name, next } = readName(msg, off); + off = next; + const type = dv.getUint16(off); + const cls = dv.getUint16(off + 2); + const ttl = dv.getUint32(off + 4); + const rdlen = dv.getUint16(off + 8); + const rdStart = off + 10; + const rdata = msg.slice(rdStart, rdStart + rdlen); + rrs.push({ + name, type, cls, ttl, rdata, section: i < an ? 'answer' : i < an + ns ? 'authority' : 'additional', + // rdata with any compressed names expanded (needed for canonical forms) + rdataExpanded: expandRdata(msg, type, rdStart, rdlen), + }); + off = rdStart + rdlen; + } + return { rcode, rrs }; +} + +// CNAME rdata may use compression pointers; canonical form needs it expanded. +function expandRdata(msg, type, rdStart, rdlen) { + if (type === TYPE.CNAME) return nameToWire(readName(msg, rdStart).name); + return msg.slice(rdStart, rdStart + rdlen); +} + +async function dohQuery(name, qtype, base = DOH) { + const q = buildQuery(name, qtype); + const b64 = btoa(String.fromCharCode(...q)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + const r = await fetch(`${base}?dns=${b64}`, { headers: { accept: 'application/dns-message' } }); + if (!r.ok) throw new Error(`DoH ${r.status}`); + return parseMessage(await r.arrayBuffer()); +} +async function query(name, qtype) { + try { return await dohQuery(name, qtype, DOH); } catch { return dohQuery(name, qtype, DOH_FALLBACK); } +} + +// --------------------------------------------------------------------------- +// RRSIG verification (RFC 4034) +// --------------------------------------------------------------------------- + +function parseRrsig(rdata) { + const dv = new DataView(rdata.buffer, rdata.byteOffset, rdata.byteLength); + const { name: signer, next } = readName(rdata, 18); + return { + typeCovered: dv.getUint16(0), algorithm: rdata[2], labels: rdata[3], + origTtl: dv.getUint32(4), expiration: dv.getUint32(8), inception: dv.getUint32(12), + keyTag: dv.getUint16(16), signer, + signature: rdata.slice(next), + signedPart: rdata.slice(0, next), // rdata up to and including signer name + }; +} + +function parseDnskey(rdata) { + const dv = new DataView(rdata.buffer, rdata.byteOffset, rdata.byteLength); + return { flags: dv.getUint16(0), protocol: rdata[2], algorithm: rdata[3], publicKey: rdata.slice(4) }; +} + +function keyTag(rdata) { + let ac = 0; + for (let i = 0; i < rdata.length; i++) ac += (i & 1) ? rdata[i] : rdata[i] << 8; + ac += (ac >> 16) & 0xffff; + return ac & 0xffff; +} + +const hexOf = (u8) => [...u8].map((b) => b.toString(16).padStart(2, '0')).join(''); +const b64url = (u8) => btoa(String.fromCharCode(...u8)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + +async function verifySig(algorithm, publicKey, data, signature) { + const subtle = globalThis.crypto?.subtle; + if (!subtle) throw new Error('WebCrypto unavailable'); + if (algorithm === 13) { // ECDSA P-256 / SHA-256; key = x||y (64b), sig = r||s (64b) + if (publicKey.length !== 64 || signature.length !== 64) throw new Error('bad P-256 material'); + const key = await subtle.importKey('jwk', { + kty: 'EC', crv: 'P-256', x: b64url(publicKey.slice(0, 32)), y: b64url(publicKey.slice(32)), + }, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']); + return subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, key, signature, data); + } + if (algorithm === 8) { // RSASSA-PKCS1-v1_5 / SHA-256; key = expLen|exp|modulus + let expLen = publicKey[0], off = 1; + if (expLen === 0) { expLen = (publicKey[1] << 8) | publicKey[2]; off = 3; } + const e = publicKey.slice(off, off + expLen); + const n = publicKey.slice(off + expLen); + if (n.length < 128) throw new Error('RSA key under 1024 bits'); // BIP-353 floor + const key = await subtle.importKey('jwk', { + kty: 'RSA', n: b64url(n), e: b64url(e), + }, { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['verify']); + return subtle.verify('RSASSA-PKCS1-v1_5', key, signature, data); + } + throw new Error(`unsupported DNSSEC algorithm ${algorithm}`); +} + +// The RRSIG-signed data: RRSIG rdata (sans signature) + canonical RRset. +function signedData(rrsig, rrs) { + // canonical owner: lowercase; wildcard expansion drops leftmost labels + const canonOwner = (name) => { + const labels = name.replace(/\.$/, '').split('.').filter(Boolean); + const owner = labels.length > rrsig.labels ? ['*', ...labels.slice(labels.length - rrsig.labels)] : labels; + return nameToWire(owner.join('.')); + }; + const parts = [rrsig.signedPart]; + const canon = rrs.map((rr) => { + const owner = canonOwner(rr.name); + const rdata = rr.rdataExpanded; + const head = new Uint8Array(owner.length + 10); + head.set(owner, 0); + const dv = new DataView(head.buffer); + dv.setUint16(owner.length, rr.type); + dv.setUint16(owner.length + 2, rr.cls); + dv.setUint32(owner.length + 4, rrsig.origTtl); + dv.setUint16(owner.length + 8, rdata.length); + const whole = new Uint8Array(head.length + rdata.length); + whole.set(head, 0); + whole.set(rdata, head.length); + return whole; + }).sort((a, b) => { // canonical rdata order + for (let i = 0; i < Math.min(a.length, b.length); i++) { if (a[i] !== b[i]) return a[i] - b[i]; } + return a.length - b.length; + }); + parts.push(...canon); + const total = parts.reduce((n2, p) => n2 + p.length, 0); + const out = new Uint8Array(total); + let off = 0; + for (const p of parts) { out.set(p, off); off += p.length; } + return out; +} + +// Verify an RRset against a set of trusted DNSKEYs of its zone. +async function verifyRrset(rrs, rrsigs, trustedKeys, zone) { + const t = now(); + for (const sigRr of rrsigs) { + const sig = parseRrsig(sigRr.rdataExpanded); + if (sig.typeCovered !== rrs[0].type) continue; + if (sig.signer !== zone) continue; + if (sig.inception > t + CLOCK_SKEW || sig.expiration < t - CLOCK_SKEW) continue; + for (const k of trustedKeys) { + if (k.tag !== sig.keyTag || k.parsed.algorithm !== sig.algorithm) continue; + try { + if (await verifySig(sig.algorithm, k.parsed.publicKey, signedData(sig, rrs), sig.signature)) return true; + } catch {} + } + } + return false; +} + +// --------------------------------------------------------------------------- +// the chain walk +// --------------------------------------------------------------------------- + +// Establish this zone's trusted DNSKEY set from DS records validated in the +// parent (or the hardcoded root anchors for '.'). +async function trustedZoneKeys(zone, dsSet) { + const resp = await query(zone || '.', TYPE.DNSKEY); + const keyRrs = resp.rrs.filter((r) => r.type === TYPE.DNSKEY && r.name === zone && r.section === 'answer'); + const sigRrs = resp.rrs.filter((r) => r.type === TYPE.RRSIG && r.name === zone && r.section === 'answer'); + if (!keyRrs.length) throw new Error(`no DNSKEY for ${zone || '.'}`); + const keys = keyRrs.map((r) => ({ rr: r, parsed: parseDnskey(r.rdataExpanded), tag: keyTag(r.rdataExpanded) })); + // Which keys are anchored by DS? DS digest = SHA-256(owner wire | DNSKEY rdata) + const anchored = keys.filter((k) => (k.parsed.flags & 0x0001) && dsSet.some((ds) => { + if (ds.keyTag !== k.tag || ds.algorithm !== k.parsed.algorithm) return false; + const owner = nameToWire(zone || '.'); + const buf = new Uint8Array(owner.length + k.rr.rdataExpanded.length); + buf.set(owner, 0); + buf.set(k.rr.rdataExpanded, owner.length); + return hexOf(sha256(buf)) === ds.digest; + })); + if (!anchored.length) throw new Error(`no DNSKEY matches DS for ${zone || '.'}`); + // The whole DNSKEY RRset must be signed by an anchored key. + if (!(await verifyRrset(keyRrs, sigRrs, anchored, zone))) { + throw new Error(`DNSKEY RRset for ${zone || '.'} fails validation`); + } + return keys; // all keys (KSK+ZSK) are now trusted for this zone +} + +function parseDs(rdata) { + const dv = new DataView(rdata.buffer, rdata.byteOffset, rdata.byteLength); + return { keyTag: dv.getUint16(0), algorithm: rdata[2], digestType: rdata[3], digest: hexOf(rdata.slice(4)) }; +} + +// Fetch DS for `child` and validate it in the (already-trusted) parent zone. +async function validatedDs(child, parentZone, parentKeys) { + const resp = await query(child, TYPE.DS); + const dsRrs = resp.rrs.filter((r) => r.type === TYPE.DS && r.name === child && r.section === 'answer'); + if (!dsRrs.length) return null; // unsigned delegation (or none) — fail closed upstream + const sigRrs = resp.rrs.filter((r) => r.type === TYPE.RRSIG && r.name === child && r.section === 'answer'); + if (!(await verifyRrset(dsRrs, sigRrs, parentKeys, parentZone))) { + throw new Error(`DS for ${child} fails validation`); + } + const all = dsRrs.map((r) => parseDs(r.rdataExpanded)); + const usable = all.filter((d) => d.digestType === 2); // BIP-353: no SHA-1 + if (!usable.length) throw new Error(`no SHA-256 DS for ${child}`); + return usable; +} + +// Trusted DNSKEY set for the zone containing `name`, walking down from the +// root. Zone cuts are discovered by trying DS at each label boundary. +async function keysForName(name) { + let zone = ''; + let keys = await trustedZoneKeys('', ROOT_ANCHORS.map((a) => ({ ...a, digest: a.digest.toLowerCase() }))); + const labels = name.replace(/\.$/, '').split('.').filter(Boolean); + for (let i = labels.length - 1; i >= 0; i--) { + const child = labels.slice(i).join('.'); + const ds = await validatedDs(child, zone, keys); + if (!ds) continue; // not a signed zone cut — stays in the current zone + keys = await trustedZoneKeys(child, ds); + zone = child; + } + return { zone, keys }; +} + +// --------------------------------------------------------------------------- +// public API +// --------------------------------------------------------------------------- + +// Resolve TXT at `name` with full DNSSEC validation, following CNAMEs. +// Returns { txts: string[] } — every returned string is provably signed. +// Throws when the chain does not validate. (No NSEC support: a missing or +// unsigned record throws rather than proving absence — fail closed.) +export async function resolveTxtValidated(name, { maxCnames = 5 } = {}) { + let target = name.toLowerCase().replace(/\.$/, ''); + for (let hop = 0; hop <= maxCnames; hop++) { + const resp = await query(target, TYPE.TXT); + const txtRrs = resp.rrs.filter((r) => r.type === TYPE.TXT && r.name === target && r.section === 'answer'); + const cnameRrs = resp.rrs.filter((r) => r.type === TYPE.CNAME && r.name === target && r.section === 'answer'); + const sigRrs = resp.rrs.filter((r) => r.type === TYPE.RRSIG && r.name === target && r.section === 'answer'); + if (txtRrs.length) { + const { zone, keys } = await keysForName(target); + if (!zone) throw new Error('record is not in a signed zone'); + if (!(await verifyRrset(txtRrs, sigRrs, keys, zone))) { + throw new Error(`TXT for ${target} fails DNSSEC validation`); + } + const txts = txtRrs.map((rr) => { + const rd = rr.rdataExpanded; + let off = 0, out = ''; + while (off < rd.length) { const l = rd[off]; out += new TextDecoder().decode(rd.slice(off + 1, off + 1 + l)); off += 1 + l; } + return out; + }); + return { txts }; + } + if (cnameRrs.length) { + const { zone, keys } = await keysForName(target); + if (!zone) throw new Error('CNAME is not in a signed zone'); + if (!(await verifyRrset(cnameRrs, sigRrs, keys, zone))) { + throw new Error(`CNAME at ${target} fails DNSSEC validation`); + } + target = readName(cnameRrs[0].rdataExpanded, 0).name; + continue; + } + throw new Error(`no TXT record at ${target}`); + } + throw new Error('too many CNAME hops'); +} diff --git a/src/features/ark.js b/src/features/ark.js index c8a6b23..5ffd121 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -456,15 +456,16 @@ export function arkFeature(ctx) { // full manager state plus the chain-3 keys for its live coins and a window // of upcoming change indices (the worker cannot derive). nwc.js decides // WHEN to mirror (background answering on) and supplies the connections. - async function writeBgMirror(connections) { + async function writeBgMirror(connections, offer) { const cfg = getArkConfig(); if (!cfg || !ark || !ark.info || !ark.state) return; const prev = await loadBg(wallet._cacheKey()); const rec = buildBg({ ark: { arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: getNetwork(), serverPubkey: ark.info.serverPubkey }, mgrState: ark.state, - keyFor: (i) => hex.encode(ark._key(i).privkey), + keyFor: (chain, i) => hex.encode(ark.account.deriveChild(chain).deriveChild(i).privateKey), connections, + offer, }); if (prev) rec.spends = prev.spends; // the worker's log survives rewrites await saveBg(wallet._cacheKey(), rec); @@ -1576,7 +1577,7 @@ export function arkFeature(ctx) { arkBgReady() { return !!(ark && ark.info && ark.state); }, // nwc.js calls this (with its connections) whenever the mirror should // refresh: on enable, on connection changes, after payments, on a timer. - async arkBgWrite(connections) { return writeBgMirror(connections); }, + async arkBgWrite(connections, offer) { return writeBgMirror(connections, offer); }, async arkBgSpendableSat() { const rec = await loadBg(wallet._cacheKey()); return ((rec && rec.mgr && rec.mgr.vtxos) || []) diff --git a/src/features/names.js b/src/features/names.js index d059516..d932af7 100644 --- a/src/features/names.js +++ b/src/features/names.js @@ -126,7 +126,7 @@ export function namesFeature(ctx) { if (!available()) return null; const st = load(); if (st.name) { - const addr = `₿${st.name}@${DOMAIN}`; + const addr = `${st.name}@${DOMAIN}`; return h('div', { class: 'card col' }, h('h3', {}, t('namesTitle')), h('div', { class: 'addr-box break', style: 'font-size:14px' }, addr), @@ -135,6 +135,14 @@ export function namesFeature(ctx) { h('button', { class: 'btn-ghost btn-sm', onClick: async () => { await release(); toast(t('namesReleased')); render(); } }, t('namesRelease'))), + (() => { + const code = hook('nwcOfferString'); + return code ? h('details', { class: 'small faint' }, + h('summary', {}, t('namesZapCode')), + h('p', { style: 'margin:4px 0' }, t('namesZapCodeHow')), + h('div', { class: 'addr-box break', style: 'font-size:10px' }, code), + copyBtn(code, t('namesZapCodeCopy'))) : null; + })(), h('details', { class: 'small faint' }, h('summary', {}, t('namesOwnDomain')), h('p', { style: 'margin:4px 0' }, t('namesOwnDomainHow')), diff --git a/src/features/nwc.js b/src/features/nwc.js index f7e42c8..8ef9cb9 100644 --- a/src/features/nwc.js +++ b/src/features/nwc.js @@ -30,6 +30,7 @@ import { t } from '../i18n.js'; import { qrSvg } from '../qr.js'; import { getNetwork } from '../api.js'; import { loadBg, saveBg, clearBg } from '../nwc-bg.js'; +import { encodeNoffer } from '../noffer.js'; import { maybeBolt11 } from '../ark/lightning.js'; const REQ_KIND = 23194; @@ -159,10 +160,22 @@ export function nwcFeature(ctx) { domain: 'nwc', // published as its own sync slot — see splitSnapshotDomains mergeAlways: true, // the merge is commutative, so older snapshots are fine save: () => { - const list = (load().conns || []); - return list.length ? { nwcConns: list } : {}; + const st = load(); + const out = {}; + if ((st.conns || []).length) out.nwcConns = st.conns; + if (st.offer) out.nwcOffer = st.offer; + return out; }, load: (d) => { + // the offer keypair: whichever device minted one first wins, so every + // device answers for the SAME offer code + if (d.nwcOffer && d.nwcOffer.sk) { + const st0 = load(); + if (!st0.offer || (d.nwcOffer.created || 0) < (st0.offer.created || Infinity)) { + st0.offer = d.nwcOffer; + save(st0); + } + } if (!Array.isArray(d.nwcConns)) return; const st = load(); const merged = mergeConns(st.conns || [], d.nwcConns); @@ -376,6 +389,109 @@ export function nwcFeature(ctx) { return errRes('NOT_IMPLEMENTED', 'method not supported'); } + // ---- CLINK offer (a static, zappable payment code) --------------------- + // One offer keypair per wallet, synced across devices like connections. + // Anyone with the code can request an invoice (kind 21001); every open tab + // answers, and the service worker answers while closed. Amounts are minted + // straight against the wallet's own balance — nothing custodial. + const OFFER_KIND = 21001; + function offerKeys() { + const st = load(); + if (st.offer) return st.offer; + const sk = generateSecretKey(); + st.offer = { sk: hex.encode(sk), pk: getPublicKey(sk), created: Date.now() }; + save(st); + try { wallet.saveCache(); } catch {} + return st.offer; + } + const offerString = () => { + const o = offerKeys(); + return encodeNoffer({ pubkey: o.pk, relay: NWC_RELAYS[0], offerId: 'zap_default', priceType: 2 }); + }; + + async function onOfferRequest(ev) { + if (ev.kind !== OFFER_KIND) return; + if (handled.has(ev.id)) return; + handled.add(ev.id); + if (ev.created_at && nowSec() - ev.created_at > MAX_AGE_SEC) return; + const o = load().offer; + if (!o) return; + const sk = hex.decode(o.sk); + let req; + try { req = JSON.parse(nip44.decrypt(ev.content, nip44.getConversationKey(sk, ev.pubkey))); } catch { return; } + const sendBack = async (payload) => { + const content = nip44.encrypt(JSON.stringify(payload), nip44.getConversationKey(sk, ev.pubkey)); + const evt = finalizeEvent({ + kind: OFFER_KIND, created_at: nowSec(), + tags: [['p', ev.pubkey], ['e', ev.id], ['clink_version', '1']], + content, + }, sk); + await publish(NWC_RELAYS, evt); + }; + // multi-device: stay silent if another device answered already + const prior = await query(NWC_RELAYS, { kinds: [OFFER_KIND], '#e': [ev.id] }, 1200).catch(() => []); + if (prior && prior.length) return; + const sat = Math.floor(req.amount_sats || 0); + if (!sat || sat < 1) return sendBack({ error: 'Invalid Amount', code: 5, range: { min: 1, max: 1000000 } }); + try { + const inv = await hook('arkMakeInvoice', sat, (req.description || '').slice(0, 100)); + if (req.zap) { + const st = load(); + st.zapPending = [...(st.zapPending || []), { zap: req.zap, invoice: inv.invoice, ts: Date.now() }].slice(-50); + save(st); + } + await sendBack({ bolt11: inv.invoice }); + lastSeen = { at: Date.now(), method: 'offer', scheme: 'nip44_v2' }; + render(); + } catch (e) { + console.warn('nwc: offer mint failed', e.message); + await sendBack({ error: 'Temporary Failure', code: 2 }).catch(() => {}); + } + } + + // Zap receipts: once a pending zap's invoice is actually received (the ark + // movement completes), publish the kind 9735 receipt signed by the offer + // key — that's what makes the zap show up under the note. + async function publishZapReceipts() { + const st = load(); + const o = st.offer; + let pend = st.zapPending || []; + // the worker stashes its pending zaps next to the mirror — adopt them + try { + const rec = await loadBg(wallet._cacheKey()); + if (rec && (rec.zapPending || []).length) { + pend = [...pend, ...rec.zapPending].slice(-50); + rec.zapPending = []; + await saveBg(wallet._cacheKey(), rec); + } + } catch {} + if (!o || !pend.length) return; + const moves = hook('arkMovements') || []; + const keep = []; + for (const z of pend) { + if (Date.now() - z.ts > 86400_000) continue; // expired unpaid + const paid = moves.find((m) => m.type === 'ln-receive' && m.status === 'complete' && m.invoice === z.invoice); + if (!paid) { keep.push(z); continue; } + try { + const zapReq = JSON.parse(z.zap); + const tags = [ + ...zapReq.tags.filter((t2) => t2[0] === 'p' || t2[0] === 'e'), + ['P', zapReq.pubkey], + ['bolt11', z.invoice], + ['description', z.zap], + ]; + if (paid.preimage) tags.push(['preimage', paid.preimage]); + const receipt = finalizeEvent({ kind: 9735, created_at: nowSec(), tags, content: '' }, hex.decode(o.sk)); + const relayTag = zapReq.tags.find((t2) => t2[0] === 'relays'); + const relays = [...new Set([...(relayTag ? relayTag.slice(1) : []), ...NWC_RELAYS])].slice(0, 8); + await publish(relays, receipt); + } catch (e) { console.warn('nwc: zap receipt failed', e.message); } + } + const st2 = load(); + st2.zapPending = keep; + save(st2); + } + // ---- lifecycle -------------------------------------------------------- let unsubs = []; @@ -391,9 +507,17 @@ export function nwcFeature(ctx) { listenGen++; const gen = listenGen; if (wallet.watchOnly) return; + // the offer subscription rides the same lifecycle as connections + if (hook('arkReady')) { + const o = offerKeys(); + unsubs.push(subscribe( + NWC_RELAYS, + { kinds: [OFFER_KIND], '#p': [o.pk], since: nowSec() - MAX_AGE_SEC }, + (ev) => { onOfferRequest(ev).catch((e) => console.warn('nwc: offer handler', e.message)); }, + )); + } const list = conns(); console.log(`nwc: listen() gen ${gen} — ${list.length} connection(s)`); - if (!list.length) return; for (const c of list) { console.log(`nwc: gen ${gen} subscribing for conn=${c.id} service=${c.servicePk.slice(0, 8)}`); unsubs.push(subscribe( @@ -442,10 +566,11 @@ export function nwcFeature(ctx) { userVisibleOnly: true, applicationServerKey: b64ToBytes(publicKey), }); - const list = conns(); + const pks1 = conns().map((c) => c.servicePk); + if (load().offer) pks1.push(load().offer.pk); const r = await fetch(`${NOTIFIER}/register`, { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ subscription: sub.toJSON(), servicePubkeys: list.map((c) => c.servicePk) }), + body: JSON.stringify({ subscription: sub.toJSON(), servicePubkeys: pks1 }), }); if (!r.ok) throw new Error(`notifier refused: ${r.status}`); const st = load(); st.background = true; save(st); @@ -468,11 +593,12 @@ export function nwcFeature(ctx) { const reg = await navigator.serviceWorker.ready; const sub = await reg.pushManager.getSubscription(); if (!sub) return; - const list = conns(); - if (!list.length) return; + const pks = conns().map((c) => c.servicePk); + if (load().offer) pks.push(load().offer.pk); + if (!pks.length) return; await fetch(`${NOTIFIER}/register`, { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ subscription: sub.toJSON(), servicePubkeys: list.map((c) => c.servicePk) }), + body: JSON.stringify({ subscription: sub.toJSON(), servicePubkeys: pks }), }); } catch (e) { console.warn('nwc: notifier re-register failed', e.message); } }, 1500); @@ -506,7 +632,7 @@ export function nwcFeature(ctx) { bgTimer = setTimeout(async () => { try { if (!hook('arkBgReady')) return; - await hook('arkBgWrite', conns()); + await hook('arkBgWrite', conns(), load().offer || null); } catch (e) { console.warn('nwc: could not write background state', e.message); } }, 1500); } @@ -530,6 +656,7 @@ export function nwcFeature(ctx) { await saveBg(wallet._cacheKey(), rec); } writeBg(); // refresh the mirror after the merge settled + publishZapReceipts().catch(() => {}); render(); } catch (e) { console.warn('nwc: background reconcile failed', e.message); } } @@ -550,7 +677,7 @@ export function nwcFeature(ctx) { let watchdog = null; function startWatchdog() { if (watchdog) return; - watchdog = setInterval(() => { if (conns().length) { listen(); writeBg(); } }, 90 * 1000); + watchdog = setInterval(() => { listen(); writeBg(); publishZapReceipts().catch(() => {}); }, 90 * 1000); if (typeof window !== 'undefined') { window.addEventListener('online', () => listen()); document.addEventListener('visibilitychange', () => { @@ -635,6 +762,7 @@ export function nwcFeature(ctx) { return { id: 'nwc', + nwcOfferString() { return hook('arkReady') && !wallet.watchOnly ? offerString() : null; }, init() { listen(); startWatchdog(); refreshRegistration(); reconcileBg(); }, stop() { stop(); if (watchdog) { clearInterval(watchdog); watchdog = null; } }, settingsCards() { return [nwcCard()]; }, diff --git a/src/i18n.js b/src/i18n.js index 16da899..62e7b07 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -429,18 +429,21 @@ const STR = { arkLnRefunded: 'Payment failed — funds returned to your Ark balance.', arkLnPaidTitle: 'Paid!', namesTitle: 'Payment address', - namesDesc: 'Claim a human-readable payment address (₿you@halwallet.app) that anyone with a BIP-353 wallet can pay. It points at this wallet’s reusable Ark address and follows the wallet if that changes.', + namesDesc: 'Claim your payment address — like you@halwallet.app. People can pay you just by typing it, no codes to copy or QR to scan.', namesPlaceholder: 'username', namesClaim: 'Claim', namesClaimed: 'You are now {name}', namesCopy: 'Copy address', namesRelease: 'Release', namesReleased: 'Name released', - namesNeedArk: 'Ark must be connected to claim a name', + namesNeedArk: 'Your wallet is still connecting — try again in a moment', namesNotFound: 'No payment record found for {name}', - namesNoUsableInstruction: 'That name has a payment record, but nothing this wallet can pay', + namesNoUsableInstruction: 'That address exists, but this wallet has no way to pay it', + namesZapCode: 'Get zapped on Nostr', + namesZapCodeHow: 'Paste this code into your Nostr app profile and zaps land straight in this wallet:', + namesZapCodeCopy: 'Copy code', namesOwnDomain: 'Use your own domain', - namesOwnDomainHow: 'Add this CNAME in your DNS (your zone must have DNSSEC enabled) and your name works at your own domain:', + namesOwnDomainHow: 'Have your own domain? Add this line to its DNS records (DNSSEC must be enabled) and your address works there too:', 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.', diff --git a/src/noffer.js b/src/noffer.js new file mode 100644 index 0000000..a211f66 --- /dev/null +++ b/src/noffer.js @@ -0,0 +1,55 @@ +// CLINK offer codes (`noffer1…`) — a static, nostr-native payment code. +// Bech32 over TLV: 0 = service pubkey (32B), 1 = relay url, 2 = offer id, +// 3 = pricing type (2 = payer chooses the amount). A payer decodes this, +// sends a NIP-44-encrypted kind-21001 request to the pubkey via the relay, +// and gets an invoice back — no web server involved. + +import { bech32 } from '@scure/base'; +import { hex } from '@scure/base'; + +const te = new TextEncoder(); +const td = new TextDecoder(); + +function tlv(items) { + const parts = []; + for (const [type, value] of items) { + if (value.length > 255) throw new Error('tlv value too long'); + parts.push(new Uint8Array([type, value.length]), value); + } + const total = parts.reduce((n, p) => n + p.length, 0); + const out = new Uint8Array(total); + let off = 0; + for (const p of parts) { out.set(p, off); off += p.length; } + return out; +} + +export function encodeNoffer({ pubkey, relay, offerId, priceType = 2 }) { + const data = tlv([ + [0, hex.decode(pubkey)], + [1, te.encode(relay)], + [2, te.encode(offerId)], + [3, new Uint8Array([priceType])], + ]); + return bech32.encode('noffer', bech32.toWords(data), 2000); +} + +export function decodeNoffer(str) { + const { prefix, words } = bech32.decode(String(str).trim(), 2000); + if (prefix !== 'noffer') throw new Error('not an offer code'); + const data = bech32.fromWords(words); + const out = {}; + let off = 0; + while (off + 2 <= data.length) { + const type = data[off], len = data[off + 1]; + const value = data.slice(off + 2, off + 2 + len); + off += 2 + len; + if (type === 0) out.pubkey = hex.encode(value); + else if (type === 1) out.relay = td.decode(value); + else if (type === 2) out.offerId = td.decode(value); + else if (type === 3) out.priceType = value[0]; + else if (type === 4) out.priceSats = [...value].reduce((n, b) => n * 256 + b, 0); + else if (type === 5) out.currency = td.decode(value); + } + if (!out.pubkey) throw new Error('offer code has no pubkey'); + return out; +} diff --git a/src/nwc-bg.js b/src/nwc-bg.js index f539e45..af0bb6d 100644 --- a/src/nwc-bg.js +++ b/src/nwc-bg.js @@ -79,20 +79,31 @@ export async function allBgs() { // cannot derive keys, so its allocations must land inside this window. export const BG_KEY_LOOKAHEAD = 25; -// { ark } endpoints, the manager state to mirror, and a keyFor(index) derive -// function (the caller owns the account node — it never enters this module). -export function buildBg({ ark, mgrState, keyFor, connections }) { +// { ark } endpoints, the manager state to mirror, and a keyFor(chain, index) +// derive function (the caller owns the account node — it never enters this +// module). Chain 3 covers coins + change, chain 4/0 the mailbox, chain 5 a +// window of receive preimages — everything the worker needs to pay AND to +// mint invoices while the app is closed. +export function buildBg({ ark, mgrState, keyFor, connections, offer }) { const keys = {}; const idx = new Set((mgrState.vtxos || []).filter((v) => v.state !== 'spent').map((v) => v.keyIndex)); for (let i = 0; i < BG_KEY_LOOKAHEAD; i++) idx.add((mgrState.nextKeyIndex || 1) + i); idx.add(0); // the receive-address key: mailbox/refund defaults land on it - for (const i of idx) keys[String(i)] = keyFor(i); + for (const i of idx) keys[String(i)] = keyFor(3, i); + const keys5 = {}; + for (let i = 0; i < 15; i++) { + const n = (mgrState.nextLnRecvIndex || 0) + i; + keys5[String(n)] = keyFor(5, n); + } return { v: 3, updated: Date.now(), ark, mgr: mgrState, keys, + keys5, + key4: keyFor(4, 0), + offer: offer || null, // { sk, pk } — the CLINK offer service keypair connections: (connections || []).map((c) => ({ id: c.id, servicePk: c.servicePk, serviceSk: c.serviceSk, clientPk: c.clientPk, maxSat: c.maxSat, dailySat: c.dailySat, diff --git a/src/nwc-respond.js b/src/nwc-respond.js index c0897d1..83f23a0 100644 --- a/src/nwc-respond.js +++ b/src/nwc-respond.js @@ -46,6 +46,80 @@ const spendableSat = (rec) => ((rec.mgr && rec.mgr.vtxos) || []).filter((v) => v.state === 'spendable') .reduce((n, v) => n + (v.amountSat || 0), 0); +// The worker can't derive keys — the mirror carries them per chain: +// 3 = coins/change, 4/0 = mailbox, 5 = receive preimages. +function shimAccount(rec) { + const byChain = (chain, i) => { + const k = chain === 5 ? rec.keys5?.[String(i)] + : chain === 4 ? rec.key4 + : rec.keys[String(i)]; + if (!k) throw new Error(`chain ${chain} index ${i} outside the mirrored window`); + return hex.decode(k); + }; + return { deriveChild: (chain) => ({ deriveChild: (i) => ({ privateKey: byChain(chain, i) }) }) }; +} + +async function bgManager(rec, walletKey, saveFn) { + const storage = { + load: () => rec.mgr, + save: (s) => { rec.mgr = s; saveFn(walletKey, rec).catch(() => {}); }, + }; + return new ArkManager({ + account: shimAccount(rec), storage, + arkUrl: rec.ark.arkUrl, esploraUrl: rec.ark.esploraUrl, network: rec.ark.network, + }).init(); +} + +// A CLINK offer request (kind 21001): mint an invoice against the mirrored +// wallet and reply. The payment itself arrives later over the ASP's hold +// invoice — the app claims it on next open, so we also ask the caller to +// raise a "money incoming" notification. +async function respondOffer(ev, rec, walletKey, { notifier, fetchFn, saveFn, log }) { + const offer = rec.offer; + if (!offer || !offer.sk) return false; + const sk = hex.decode(offer.sk); + let req; + try { req = JSON.parse(nip44.decrypt(ev.content, nip44.getConversationKey(sk, ev.pubkey))); } catch { return false; } + const publish = async (payload) => { + const content = nip44.encrypt(JSON.stringify(payload), nip44.getConversationKey(sk, ev.pubkey)); + const evt = finalizeEvent({ + kind: 21001, created_at: nowSec(), + tags: [['p', ev.pubkey], ['e', ev.id], ['clink_version', '1']], + content, + }, sk); + const r = await fetchFn(`${notifier}/publish`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ event: evt }), + }); + if (!r.ok) throw new Error(`publish refused: ${r.status}`); + }; + try { + const answered = await fetchFn(`${notifier}/answered?event=${ev.id}`).then((r) => r.json()).catch(() => ({})); + if (answered.answered) return true; + const sat = Math.floor(req.amount_sats || 0); + if (!sat || sat < 1) { await publish({ error: 'Invalid Amount', code: 5, range: { min: 1, max: 1000000 } }); return true; } + if (!rec.keys5 || !rec.key4) return false; // old mirror: wake the user + if (!rec.keys5[String(rec.mgr.nextLnRecvIndex || 0)]) return false; // preimage window exhausted + const mgr = await bgManager(rec, walletKey, saveFn); + const a = await mgr.createLnInvoice(sat, (req.description || '').slice(0, 100)); + // pending zap receipts are published by the app when it claims — stash + // what it needs alongside the mirror + if (req.zap) { + rec.zapPending = rec.zapPending || []; + rec.zapPending.push({ zap: req.zap, invoice: a.invoice, ts: Date.now() }); + if (rec.zapPending.length > 50) rec.zapPending = rec.zapPending.slice(-50); + } + await saveFn(walletKey, rec); + await publish({ bolt11: a.invoice }); + log(`minted ${sat} sat offer invoice while closed`); + return { notify: { title: 'Incoming payment', body: 'Someone is paying you — open Hal to receive it.' } }; + } catch (e) { + log('offer mint failed: ' + e.message); + await publish({ error: 'Temporary Failure', code: 2 }).catch(() => {}); + return true; + } +} + // data: the push payload { type:'nwc', servicePubkey, event }. Returns true // when the request was fully dealt with (reply published or deliberately // left to another device); false means "wake the user instead". @@ -53,11 +127,23 @@ export async function respondFromBg(data, { notifier, fetchFn = fetch, recordsFn = allBgs, saveFn = saveBg, log = () => {}, } = {}) { const ev = data && data.event; - if (!ev || ev.kind !== 23194 || !ev.id || !ev.pubkey) return false; + if (!ev || !ev.id || !ev.pubkey) return false; if (nowSec() - (ev.created_at || 0) > MAX_AGE_SEC) return false; const target = (ev.tags?.find((t) => t[0] === 'p') || [])[1]; if (!target) return false; + + // CLINK offer requests route by the offer service pubkey. + if (ev.kind === 21001) { + for (const r of await recordsFn()) { + if (r.rec.v >= 3 && r.rec.offer?.pk === target) { + return respondOffer(ev, r.rec, r.walletKey, { notifier, fetchFn, saveFn, log }); + } + } + return false; + } + + if (ev.kind !== 23194) return false; let walletKey = null, rec = null, conn = null; for (const r of await recordsFn()) { const c = (r.rec.connections || []).find((x) => x.servicePk === target); @@ -148,21 +234,9 @@ export async function respondFromBg(data, { // outputs allocate from nextKeyIndex, which must stay inside the key // window the app pre-derived; if it ran out, wake the user instead. if (!rec.keys[String(rec.mgr.nextKeyIndex || 1)]) { log('key window exhausted'); return false; } - const storage = { - load: () => rec.mgr, - save: (s) => { rec.mgr = s; saveFn(walletKey, rec).catch(() => {}); }, - }; - const keyShim = { deriveChild: () => ({ deriveChild: (i) => { - const k = rec.keys[String(i)]; - if (!k) throw new Error(`key index ${i} outside the mirrored window`); - return { privateKey: hex.decode(k) }; - } }) }; let mgr; try { - mgr = await new ArkManager({ - account: keyShim, storage, - arkUrl: rec.ark.arkUrl, esploraUrl: rec.ark.esploraUrl, network: rec.ark.network, - }).init(); + mgr = await bgManager(rec, walletKey, saveFn); } catch (e) { log('ark init failed: ' + e.message); return false; } let id; diff --git a/src/sw-nwc.js b/src/sw-nwc.js index 8267b6b..968be91 100644 --- a/src/sw-nwc.js +++ b/src/sw-nwc.js @@ -29,6 +29,17 @@ self.addEventListener('push', (e) => { } catch (err) { console.warn('[sw-nwc] auto-answer failed:', err && err.message); } + // handled-with-a-heads-up: e.g. an invoice was minted while closed and + // the user should open Hal to complete the receive + if (handled && handled.notify) { + await self.registration.showNotification(handled.notify.title, { + body: handled.notify.body, + icon: 'icon-192.png', badge: 'icon-192.png', + tag: 'nwc-incoming', renotify: true, + data: { url: './' }, + }); + return; + } if (handled) return; await self.registration.showNotification('Payment request', { body: 'An app is asking your wallet to pay. Open Hal to approve.', -- cgit v1.2.3