aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-01 20:46:12 -0700
committerAdam Soltys <[email protected]>2026-08-01 20:46:12 -0700
commited1d996f9dbd6cedc18ec293daba0cfcd745ae2b (patch)
tree6d552796e899314d402dc92f6f80886405e296b5 /src
parent5986cbf5337593eb11fd984d284bd41da7719a87 (diff)
downloadcoinosv3-ed1d996f9dbd6cedc18ec293daba0cfcd745ae2b.tar.gz
coinosv3-ed1d996f9dbd6cedc18ec293daba0cfcd745ae2b.zip
Zappable offer codes and per-name Lightning offers
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 <[email protected]>
Diffstat (limited to 'src')
-rw-r--r--src/bip353.js61
-rw-r--r--src/dnssec.js355
-rw-r--r--src/features/ark.js7
-rw-r--r--src/features/names.js10
-rw-r--r--src/features/nwc.js148
-rw-r--r--src/i18n.js11
-rw-r--r--src/noffer.js55
-rw-r--r--src/nwc-bg.js19
-rw-r--r--src/nwc-respond.js102
-rw-r--r--src/sw-nwc.js11
10 files changed, 699 insertions, 80 deletions
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 <name>.user._bitcoin-payment.<domain> 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 (₿[email protected]) 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 [email protected]. 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.',