aboutsummaryrefslogtreecommitdiffstats
path: root/src/nwc-respond.js
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/nwc-respond.js
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/nwc-respond.js')
-rw-r--r--src/nwc-respond.js102
1 files changed, 88 insertions, 14 deletions
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;