aboutsummaryrefslogtreecommitdiffstats
path: root/tools/relay-budget-test.js
blob: bb1dd11358acc205f362c99a73616e206d55b4b1 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
// What a Following feed asks of a relay, and how. Measured from the laptop
// (2026-11-02) an open of Following sent some 560 REQs in twenty seconds:
// a relay list asked per quoted note's author (one REQ per author per
// relay), the live subscriptions closed and reopened at the end of every
// refresh pass, the follows' reports fetched afresh at every reload, and
// every one-shot REQ held open on our fast relay until the slowest relay
// in the ask had answered. damus answered the home IP "banned: too many
// rate-limit violations". Now: relay lists go out in one REQ per batch,
// unchanged live subscriptions stay up, the reports sync honours the saved
// clock, each relay's REQ closes on its own EOSE, and no relay carries more
// than ten one-shot REQs at once. Run: bun tools/relay-budget-test.js
import puppeteer from './puppeteer.js';
import { cacheKeyFor } from '../src/wallet.js';
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
import { neventEncode } from 'nostr-tools/nip19';
import { buildHtml } from '../build.js';
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 html = await buildHtml({ minify: true, pwa: false });
const SK = generateSecretKey(), AUTHOR = getPublicKey(SK);
const now = () => Math.floor(Date.now() / 1000);
const post = (sk, content, ago, tags = []) => finalizeEvent({ kind: 1, created_at: now() - ago, tags, content }, sk);
// six people the follow quotes, each with one note on the relay
const quoters = Array.from({ length: 6 }, () => { const sk = generateSecretKey(); return { sk, pk: getPublicKey(sk) }; });
const quotedNotes = quoters.map((q, i) => post(q.sk, `quoted thought ${i}`, 7200 + i));
const stored = [...quotedNotes, post(SK, 'morning one', 3600), post(SK, 'morning two', 3000)];
const quotePosts = quotedNotes.map((n, i) => post(SK, `look at this ${i} nostr:${neventEncode({ id: n.id, author: n.pubkey })}`, 1200 + i * 10, [['q', n.id, '', n.pubkey]]));
stored.push(...quotePosts);
// one quoted note only a far relay holds (the wide net's job)
const farQ = generateSecretKey();
const farNote = finalizeEvent({ kind: 1, created_at: now() - 9000, tags: [], content: 'kept only on the far relay' }, farQ);
const farPost = post(SK, `and this nostr:${neventEncode({ id: farNote.id, author: farNote.pubkey })}`, 1100, [['q', farNote.id, '', farNote.pubkey]]);
stored.push(farPost);
const wideStore = [farNote];
const cached = [...quotePosts, ...stored.filter((e) => e.pubkey === AUTHOR && !quotePosts.includes(e))].sort((a, b) => b.created_at - a.created_at);
const match = (f, e) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey)) && (!f.ids || f.ids.includes(e.id)) && (!f.since || e.created_at >= f.since);

// two relays on one server: /fast answers at once, /slow holds its EOSE
const SLOW_MS = 3000;
const log = []; // { t, relay, kind: 'REQ'|'CLOSE'|'EOSE', id, filters }
const sockets = new Set();
let maxOpen = { fast: 0, slow: 0 };
const server = Bun.serve({ port: 0, fetch: (req, srv) => {
  const path = new URL(req.url).pathname;
  if (path === '/fast' || path === '/slow' || path === '/wide') return srv.upgrade(req, { data: { relay: path.slice(1), subs: new Map() } }) ? undefined : new Response('ws', { status: 426 });
  if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path));
  return new Response(html, { headers: { 'content-type': 'text/html' } });
}, websocket: {
  open(ws) { sockets.add(ws); },
  message(ws, raw) {
    let m; try { m = JSON.parse(raw); } catch { return; }
    const relay = ws.data.relay;
    if (m[0] === 'EVENT') { ws.send(JSON.stringify(['OK', m[1].id, true, ''])); return; }
    if (m[0] === 'REQ') {
      const filters = m.slice(2);
      ws.data.subs.set(m[1], filters);
      maxOpen[relay] = Math.max(maxOpen[relay], ws.data.subs.size);
      log.push({ t: Date.now(), relay, kind: 'REQ', id: m[1], filters });
      const answer = () => {
        if (!ws.data.subs.has(m[1])) return;
        for (const e of (relay === 'wide' ? wideStore : stored)) if (filters.some((f) => match(f, e))) ws.send(JSON.stringify(['EVENT', m[1], e]));
        ws.send(JSON.stringify(['EOSE', m[1]]));
        log.push({ t: Date.now(), relay, kind: 'EOSE', id: m[1] });
      };
      if (relay === 'slow') setTimeout(answer, SLOW_MS); else answer();
    } else if (m[0] === 'CLOSE') { ws.data.subs.delete(m[1]); log.push({ t: Date.now(), relay, kind: 'CLOSE', id: m[1] }); }
  },
  close(ws) { sockets.delete(ws); },
} });
const PORT = server.port;

const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
const page = await browser.newPage();
await page.setViewport({ width: 390, height: 844 });
await page.evaluateOnNewDocument((port) => {
  const Real = window.WebSocket;
  window.WebSocket = function (url, p) {
    if (/relay\.coinos\.io/.test(String(url))) url = `ws://localhost:${port}/fast`;
    else if (/relay\.damus\.io/.test(String(url))) url = `ws://localhost:${port}/slow`;
    else if (/nostr\.wine/.test(String(url))) url = `ws://localhost:${port}/wide`;
    return p ? new Real(url, p) : new Real(url);
  };
  window.WebSocket.prototype = Real.prototype;
  Object.assign(window.WebSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 });
}, PORT);
const errs = [];
page.on('pageerror', (e) => errs.push(String(e)));
const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; };
const reqs = (relay, pred) => log.filter((x) => x.kind === 'REQ' && x.relay === relay && pred(x.filters));
const isLive = (fs) => fs.some((f) => f.authors && f.since && !f.limit && f.kinds && f.kinds.includes(1));
// a subscription meant to stay up: since with no limit, or limit 0 (the DM inbox)
const isSub = (fs) => fs.some((f) => (f.since && !f.limit) || f.limit === 0);

try {
  await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'domcontentloaded' });
  await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
  await page.reload({ waitUntil: 'domcontentloaded' });
  await sleep(400);
  await click('create a new wallet'); await sleep(600);
  await page.waitForSelector('.words .w .t');
  const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
  await click('skip verification');
  await waitText('receive', 20000);
  const base = cacheKeyFor(mn + '\n');
  await page.evaluate(([k, pk, ns]) => {
    localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
    localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
    localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Early Bird', t: Date.now() } }));
    localStorage.setItem('btc-wallet-feed:' + k, 'following');
  }, [base, AUTHOR, cached]);
  log.length = 0; maxOpen = { fast: 0, slow: 0 };
  await page.reload({ waitUntil: 'domcontentloaded' });
  await waitText('receive', 20000);
  const t0 = Date.now();
  await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
  check('the quote posts are on screen', await waitFor(() => document.querySelectorAll('.notes-feed > .row[data-key]').length >= 6, null, 20000));
  await sleep(9000); // the refresh pass, the quotes' lookups, the reports sync

  console.log('\n[relay lists for the quoted notes\' authors]');
  const qpks = new Set(quoters.map((q) => q.pk));
  const listAsks = reqs('fast', (fs) => fs.some((f) => f.kinds && f.kinds.length === 1 && f.kinds[0] === 10002 && (f.authors || []).some((a) => qpks.has(a))));
  const covered = new Set(listAsks.flatMap((x) => x.filters.flatMap((f) => f.authors || [])).filter((a) => qpks.has(a)));
  check('asked for in one REQ, not one per author', listAsks.length === 1 && covered.size === 6, listAsks.length + ' REQ(s) covering ' + covered.size + ' of 6 authors');

  console.log('\n[quoted notes]');
  const wideIds = new Set(reqs('wide', (fs) => fs.some((f) => f.ids)).flatMap((x) => x.filters.flatMap((f) => f.ids || [])));
  const nearHeld = quotedNotes.filter((n) => wideIds.has(n.id)).length;
  check('the ones our relay holds were never asked of the wide net', nearHeld === 0, nearHeld + ' of 6 asked on the far relay');
  check('the one only the far relay holds was found there', wideIds.has(farNote.id) && (await page.evaluate(() => document.body.innerText.includes('kept only on the far relay'))), wideIds.has(farNote.id) ? 'asked' : 'never asked');

  console.log('\n[the live subscriptions]');
  const live = reqs('fast', isLive);
  const liveClosed = live.filter((x) => log.some((y) => y.kind === 'CLOSE' && y.relay === 'fast' && y.id === x.id));
  check('opened once and kept across the refresh', live.length === 1 && liveClosed.length === 0, live.length + ' live REQs, ' + liveClosed.length + ' closed');

  console.log('\n[one-shot REQs]');
  const fastReqs = reqs('fast', (fs) => !isSub(fs));
  const lag = [];
  for (const r of fastReqs) {
    const eose = log.find((y) => y.kind === 'EOSE' && y.relay === 'fast' && y.id === r.id);
    const close = log.find((y) => y.kind === 'CLOSE' && y.relay === 'fast' && y.id === r.id && y.t >= r.t);
    if (eose && close) { lag.push(close.t - eose.t); if (close.t - eose.t > 1500) console.log('   late close ' + (close.t - eose.t) + ' ms: ' + JSON.stringify(r.filters).slice(0, 160)); }
  }
  const slowLag = lag.filter((x) => x > 1500).length;
  check('closed on the fast relay\'s own EOSE, not the slow relay\'s', lag.length >= 10 && slowLag === 0, lag.length + ' closed, ' + slowLag + ' later than 1.5 s (slowest ' + Math.max(0, ...lag) + ' ms; the slow relay holds EOSE ' + SLOW_MS + ' ms)');
  const slowReqs = reqs('slow', () => true).length;
  check('no more than ten one-shot REQs open on a relay at once', maxOpen.slow <= 10 + 5, slowReqs + ' REQs to the slow relay, at most ' + maxOpen.slow + ' open at once (fast: ' + maxOpen.fast + ')');
  const totalFast = reqs('fast', () => true).length;
  console.log('   ' + totalFast + ' REQs to the fast relay in ' + ((Date.now() - t0) / 1000).toFixed(0) + ' s');

  console.log('\n[a reload within the half hour]');
  const reportsBefore = reqs('fast', (fs) => fs.some((f) => f.kinds && f.kinds[0] === 1984)).length;
  log.length = 0;
  await page.reload({ waitUntil: 'domcontentloaded' });
  await waitText('receive', 20000);
  await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
  await sleep(6000);
  const reportsAfter = reqs('fast', (fs) => fs.some((f) => f.kinds && f.kinds[0] === 1984)).length;
  check('the follows\' reports are not fetched again', reportsBefore >= 1 && reportsAfter === 0, reportsBefore + ' REQ(s) on the first open, ' + reportsAfter + ' after the reload');
  check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(true); }
console.log(ok ? '\n✅ the feed asks the relays for less, and asks it in fewer REQs' : '\n❌ failed');
process.exit(ok ? 0 : 1);