diff options
| author | Adam Soltys <[email protected]> | 2026-08-01 20:46:12 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-08-01 20:46:12 -0700 |
| commit | ed1d996f9dbd6cedc18ec293daba0cfcd745ae2b (patch) | |
| tree | 6d552796e899314d402dc92f6f80886405e296b5 /src/features | |
| parent | 5986cbf5337593eb11fd984d284bd41da7719a87 (diff) | |
| download | coinosv3-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/features')
| -rw-r--r-- | src/features/ark.js | 7 | ||||
| -rw-r--r-- | src/features/names.js | 10 | ||||
| -rw-r--r-- | src/features/nwc.js | 148 |
3 files changed, 151 insertions, 14 deletions
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()]; }, |
