aboutsummaryrefslogtreecommitdiffstats
path: root/tools/noffer-test.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 /tools/noffer-test.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 'tools/noffer-test.js')
-rw-r--r--tools/noffer-test.js99
1 files changed, 99 insertions, 0 deletions
diff --git a/tools/noffer-test.js b/tools/noffer-test.js
new file mode 100644
index 0000000..152ef0b
--- /dev/null
+++ b/tools/noffer-test.js
@@ -0,0 +1,99 @@
+// CLINK offer codes + the offer service: TLV roundtrip, and a full
+// kind-21001 request/response through the nwc feature with a fake transport.
+// Run: bun tools/noffer-test.js
+import { hex } from '@scure/base';
+import * as nip44 from 'nostr-tools/nip44';
+import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure';
+import { encodeNoffer, decodeNoffer } from '../src/noffer.js';
+import { nwcFeature } from '../src/features/nwc.js';
+
+let fails = 0;
+const check = (name, ok, detail = '') => {
+ console.log(` ${ok ? '✓' : '✗'} ${name}${detail ? ' — ' + String(detail).slice(0, 120) : ''}`);
+ if (!ok) fails++;
+};
+
+console.log('[offer code TLV]');
+const pk = getPublicKey(generateSecretKey());
+const code = encodeNoffer({ pubkey: pk, relay: 'wss://relay.coinos.io', offerId: 'zap_default', priceType: 2 });
+check('encodes to noffer1…', code.startsWith('noffer1'), code.slice(0, 24));
+const dec = decodeNoffer(code);
+check('pubkey roundtrips', dec.pubkey === pk);
+check('relay roundtrips', dec.relay === 'wss://relay.coinos.io');
+check('offer id roundtrips', dec.offerId === 'zap_default');
+check('price type roundtrips', dec.priceType === 2);
+
+console.log('\n[offer service]');
+// harness mirroring tools/nwc-test.js
+const store = {};
+let subHandlers = []; // every listen() subscription callback
+const published = [];
+const nwcTransport = {
+ subscribe: (relays, filter, on) => { subHandlers.push({ filter, on }); return () => {}; },
+ publish: async (relays, evt) => { published.push(evt); return true; },
+ query: async () => [],
+};
+const wallet = {
+ watchOnly: false,
+ loadFeatureState: (n, fb) => JSON.parse(store['fs:' + n] || 'null') || fb,
+ saveFeatureState: (n, st) => { store['fs:' + n] = JSON.stringify(st); },
+ _cacheKey: () => 'w1',
+ account: () => null,
+ saveCache: () => {},
+ registerCacheExtension: () => {},
+};
+const hooks = {
+ arkReady: () => true,
+ arkMakeInvoice: async (sat, desc) => ({ invoice: 'lnbc_mock_' + sat, paymentHash: 'cd'.repeat(32), amountSat: sat }),
+ arkMovements: () => [],
+ arkBgWrite: async () => {},
+ arkBgSpendableSat: async () => 0,
+};
+const ctx = {
+ h: () => null, ui: {}, render: () => {}, wallet,
+ hook: (n, ...a) => (hooks[n] ? hooks[n](...a) : null),
+ fmtAmount: String, unitLabel: () => 'sats', copyBtn: () => null, toast: () => {}, nwcTransport,
+};
+const f = nwcFeature(ctx);
+f.init();
+await new Promise((r) => setTimeout(r, 50));
+
+const offerCode = f.nwcOfferString();
+check('feature mints an offer code', !!offerCode && offerCode.startsWith('noffer1'), (offerCode || '').slice(0, 24));
+const offer = decodeNoffer(offerCode);
+const offerSub = subHandlers.find((s2) => (s2.filter.kinds || []).includes(21001));
+check('subscribed for offer requests', !!offerSub && offerSub.filter['#p'][0] === offer.pubkey);
+
+// a payer requests 21 sats with a zap attached
+const payerSk = generateSecretKey();
+const zapReq = finalizeEvent({ kind: 9734, created_at: Math.floor(Date.now() / 1000),
+ tags: [['p', 'ab'.repeat(32)], ['relays', 'wss://relay.damus.io']], content: '' }, payerSk);
+const reqPayload = { offer: 'zap_default', amount_sats: 21, zap: JSON.stringify(zapReq) };
+const convKey = nip44.getConversationKey(payerSk, offer.pubkey);
+const reqEv = finalizeEvent({
+ kind: 21001, created_at: Math.floor(Date.now() / 1000),
+ tags: [['p', offer.pubkey], ['clink_version', '1']],
+ content: nip44.encrypt(JSON.stringify(reqPayload), convKey),
+}, payerSk);
+await offerSub.on(reqEv);
+await new Promise((r) => setTimeout(r, 100));
+
+const resp = published.find((e) => e.kind === 21001);
+check('offer request answered', !!resp);
+if (resp) {
+ const body = JSON.parse(nip44.decrypt(resp.content, convKey));
+ check('response carries the invoice', body.bolt11 === 'lnbc_mock_21', JSON.stringify(body));
+ check('response e-tags the request', resp.tags.some((t) => t[0] === 'e' && t[1] === reqEv.id));
+ check('response signed by the offer key', resp.pubkey === offer.pubkey);
+}
+const st = JSON.parse(store['fs:nwc']);
+check('zap held for its receipt', (st.zapPending || []).length === 1 && st.zapPending[0].invoice === 'lnbc_mock_21');
+
+// replay: same event must not be answered twice
+const before = published.filter((e) => e.kind === 21001).length;
+await offerSub.on(reqEv);
+await new Promise((r) => setTimeout(r, 60));
+check('replayed request ignored', published.filter((e) => e.kind === 21001).length === before);
+
+console.log(fails ? `\n❌ ${fails} failure(s)` : '\n✅ offer service behaves');
+process.exit(fails ? 1 : 0);