// A gift link can name its sender: #f=, a // nostr signature over the gift's code. The claim screen shows who it's // from (and that claiming follows them); a link whose signature doesn't // match its code — passed on as someone else's gift — shows no sender. // Run: bun run build && bun tools/gift-from-test.js import puppeteer from './puppeteer.js'; import { base32nopad } from '@scure/base'; import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; import { sha256 } from '@noble/hashes/sha256'; import { bytesToHex, hexToBytes } from '@noble/hashes/utils'; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); let ok = true; const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; const server = Bun.serve({ port: 0, async fetch(req) { const p = new URL(req.url).pathname; const f = Bun.file('dist' + (p === '/' ? '/index.html' : p)); return (await f.exists()) ? new Response(f) : new Response(Bun.file('dist/index.html'), { headers: { 'content-type': 'text/html' } }); } }); const giftCode = () => { const b = new Uint8Array(42); b[0] = 0x11; b[1] = 3; // ark gift, mutinynet new DataView(b.buffer).setBigUint64(2, 1000n, true); b.set(crypto.getRandomValues(new Uint8Array(32)), 10); return base32nopad.encode(b); }; const sk = generateSecretKey(); const fromFrag = (code) => { const ev = finalizeEvent({ kind: 27121, created_at: Math.floor(Date.now() / 1000), content: '', tags: [['x', bytesToHex(sha256(new TextEncoder().encode(code.toUpperCase())))]] }, sk); const b = new Uint8Array(100); b.set(hexToBytes(ev.pubkey), 0); new DataView(b.buffer).setUint32(32, ev.created_at); b.set(hexToBytes(ev.sig), 36); return base32nopad.encode(b); }; const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] }); const text = (page) => page.evaluate(() => document.body.innerText); const waitText = async (page, re, ms = 30000) => { for (let t = 0; t < ms; t += 250) { if (re.test(await text(page))) return true; await sleep(250); } return false; }; async function open(path) { const page = await browser.newPage(); await page.goto(server.url.href); await page.evaluate(() => { localStorage.clear(); localStorage.setItem('btc-wallet-network', 'mutinynet'); }); await page.goto(server.url.href + path); await waitText(page, /aren.t visible/i); await sleep(500); return page; } try { const code = giftCode(); let page = await open(`ag/${code}#m=hi&f=${fromFrag(code)}`); let tx = await text(page); check('a signed link names its sender', /\bFrom\b/.test(tx) && /Claiming it follows them/.test(tx), tx.slice(0, 300).replace(/\n/g, ' | ')); check('the note still shows', /\bhi\b/.test(tx)); await page.close(); // an upper-case key, as a scanner may hand it over page = await open(`ag/${code}#F=${fromFrag(code)}`); { const tx = await text(page); check("an upper-cased link still names its sender", /Claiming it follows them/.test(tx), tx.slice(0, 200).replace(/\n/g, " | ")); } await page.close(); // the same signature on a different gift: no sender page = await open(`ag/${giftCode()}#f=${fromFrag(code)}`); check('a signature over another code names nobody', !/Claiming it follows them/.test(await text(page))); await page.close(); } finally { await browser.close(); server.stop(); } console.log(ok ? 'PASS' : 'FAIL'); process.exit(ok ? 0 : 1);