aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-03 22:55:03 -0700
committerAdam Soltys <[email protected]>2026-09-03 22:55:03 -0700
commit618a84ab96f1865f6112d696077390ba184dd9b3 (patch)
tree37d81fa5945dcc1395ac70479193207143140ac1
parentc0c6f017324a07fc7850cc2a6b705415b8a9e85c (diff)
downloadcoinosv3-618a84ab96f1865f6112d696077390ba184dd9b3.tar.gz
coinosv3-618a84ab96f1865f6112d696077390ba184dd9b3.zip
Nostr signature verification moves off the main thread: the pool's synchronous verifyEvent (a schnorr check per event, ~560ms on a relay backlog — the profiled carousel-stutter cause) is switched off, and every event is now gated through a Web Worker before any caller sees it. Nothing unverified is ever trusted — the worker runs the same nostr-tools verifyEvent, just on another thread, with a synchronous fallback if the worker is unavailable (standalone file://) or stalls. Bundled to dist/verify-worker.js, precached in the SW
Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01MrkLsNScXe9b6BviUSdmMr
-rw-r--r--build.js19
-rw-r--r--src/nostr.js67
-rw-r--r--src/verify-worker.js19
3 files changed, 98 insertions, 7 deletions
diff --git a/build.js b/build.js
index e2c0217..e48c4f7 100644
--- a/build.js
+++ b/build.js
@@ -42,7 +42,7 @@ const SW = `const CACHE = 'cold-{{VERSION}}';
// module are available offline too. app-{{VERSION}}.js is the main bundle —
// content-addressed, so a new deploy precaches a new name and the old cache
// is dropped wholesale.
-const SHELL = ['./', 'manifest.webmanifest', 'icon-192.png', 'icon-512.png', 'jsqr.js', 'nip46.js', 'app-{{VERSION}}.js'{{EXTRA_SHELL}}];
+const SHELL = ['./', 'manifest.webmanifest', 'icon-192.png', 'icon-512.png', 'jsqr.js', 'nip46.js', 'verify-worker.js', 'app-{{VERSION}}.js'{{EXTRA_SHELL}}];
self.addEventListener('install', (e) => {
self.skipWaiting();
@@ -181,6 +181,22 @@ export async function buildNip46({ minify = true } = {}) {
return iife(await result.outputs[0].text());
}
+// Off-main-thread nostr event verification worker (classic Worker, its own
+// bundle of nostr-tools verify + noble crypto). NOT wrapped in an IIFE —
+// worker code runs at top level and needs its self.onmessage handler live.
+export async function buildVerifyWorker({ minify = true } = {}) {
+ const result = await Bun.build({
+ entrypoints: ['./src/verify-worker.js'],
+ target: 'browser',
+ minify,
+ });
+ if (!result.success) {
+ for (const log of result.logs) console.error(log);
+ throw new Error('verify-worker bundle failed');
+ }
+ return await result.outputs[0].text();
+}
+
// The notify-only push handler for builds without the ark+nwc features (the
// full builds get the bundled auto-answering handler from src/sw-nwc.js).
const SW_WAKE_ONLY = `self.addEventListener('push', (e) => {
@@ -421,6 +437,7 @@ if (import.meta.main) {
// Lazy-loaded QR decoder — kept out of index.html, fetched only when a
// browser without BarcodeDetector opens the scanner.
+ await Bun.write('dist/verify-worker.js', await buildVerifyWorker());
await Bun.write('dist/jsqr.js', await buildJsQr());
// Lazy-loaded NIP-46 remote-signer client — fetched on first bunker login.
await Bun.write('dist/nip46.js', await buildNip46());
diff --git a/src/nostr.js b/src/nostr.js
index a76ef5c..8b6bc7e 100644
--- a/src/nostr.js
+++ b/src/nostr.js
@@ -12,7 +12,7 @@
import * as nip06 from 'nostr-tools/nip06';
import * as nip44 from 'nostr-tools/nip44';
import * as nip04 from 'nostr-tools/nip04';
-import { getPublicKey, finalizeEvent, generateSecretKey } from 'nostr-tools/pure';
+import { getPublicKey, finalizeEvent, generateSecretKey, verifyEvent } from 'nostr-tools/pure';
import { decode as nip19decode, npubEncode, nsecEncode } from 'nostr-tools/nip19';
import { wrapEvent as nip17WrapEvent } from 'nostr-tools/nip17';
import { SimplePool } from 'nostr-tools/pool';
@@ -25,7 +25,51 @@ import { base64urlnopad } from '@scure/base';
// timeout, network blip) takes every live subscription with it and nothing
// re-subscribes — sync stops merging and NWC stops answering until a reload.
// On reconnect the pool re-fires open subs with since = last event seen.
-const pool = new SimplePool({ enablePing: true, enableReconnect: true });
+// verifyEvent: () => true switches OFF the pool's own synchronous signature
+// check — a schnorr verify per event, which on a relay backlog froze the main
+// thread (the boot/chat carousel stutter). We re-add verification off-thread:
+// every event is gated through a Web Worker (verifyEventsAsync) before it
+// reaches a caller, so nothing unverified is ever trusted, but the crypto
+// runs on another thread. matchFilters still runs in the pool.
+const pool = new SimplePool({ enablePing: true, enableReconnect: true, verifyEvent: () => true });
+
+// ---- off-main-thread verification -----------------------------------------
+let _vw = null, _vwDead = false, _vwSeq = 0;
+const _vwPending = new Map(); // reqId -> resolve
+function verifyWorker() {
+ if (_vw || _vwDead) return _vw;
+ try {
+ _vw = new Worker('/verify-worker.js', { type: 'module' });
+ _vw.onmessage = (e) => {
+ const { reqId, results } = e.data || {};
+ const done = _vwPending.get(reqId);
+ if (done) { _vwPending.delete(reqId); done(results); }
+ };
+ _vw.onerror = () => { _vwDead = true; _vw = null; }; // fall back to main-thread verify
+ } catch { _vwDead = true; }
+ return _vw;
+}
+const _verifySync = (events) => events.map((e) => { try { return verifyEvent(e); } catch { return false; } });
+// Verify a batch; resolves to a boolean[] aligned to `events`. Off-thread when
+// the worker is available, else synchronous. Never resolves an event as valid
+// without an actual check.
+function verifyEventsAsync(events) {
+ if (!events.length) return Promise.resolve([]);
+ const w = verifyWorker();
+ if (!w) return Promise.resolve(_verifySync(events));
+ return new Promise((resolve) => {
+ const reqId = ++_vwSeq;
+ let settled = false;
+ const finish = (r) => { if (!settled) { settled = true; resolve(r); } };
+ // if the worker ever stalls, don't hang the event forever — verify inline
+ const t = setTimeout(() => { _vwPending.delete(reqId); finish(_verifySync(events)); }, 4000);
+ _vwPending.set(reqId, (r) => { clearTimeout(t); finish(r); });
+ try { w.postMessage({ reqId, events }); }
+ catch { clearTimeout(t); _vwPending.delete(reqId); finish(_verifySync(events)); }
+ });
+}
+// A single event → Promise<boolean>. Used to gate live subscription events.
+function verifyOneAsync(event) { return verifyEventsAsync([event]).then((r) => !!r[0]); }
// Subscribe / publish on an explicit relay set, independent of the sync
// feature's configuration. NWC needs this: the relays it advertises in a
@@ -72,13 +116,19 @@ export function subscribeOn(relays, filter, onEvent, extra = {}) {
// "provided filter is not an object" — and the subscription dies silently.
const live = liveRelays(relays);
probeRelays(relays);
- const sub = pool.subscribeMany(live, filter, { onevent: onEvent, ...extra });
+ // gate each event through off-thread verification before the caller sees it
+ const gated = (event) => { verifyOneAsync(event).then((ok) => { if (ok) onEvent(event); }); };
+ const sub = pool.subscribeMany(live, filter, { onevent: gated, ...extra });
return () => { try { sub.close(); } catch {} };
} catch { return () => {}; }
}
export async function queryOn(relays, filter, maxWait = 1500) {
probeRelays(relays);
- try { return await pool.querySync(liveRelays(relays), filter, { maxWait }); } catch { return []; }
+ try {
+ const evs = await pool.querySync(liveRelays(relays), filter, { maxWait });
+ const oks = await verifyEventsAsync(evs);
+ return evs.filter((_, i) => oks[i]);
+ } catch { return []; }
}
export async function publishOn(relays, evt) {
try {
@@ -351,14 +401,19 @@ export class NostrSync {
// Fetch events matching a filter from the relays (best-effort).
async fetchEvents(filter, maxWait = 5000) {
- try { return await pool.querySync(this.relays, filter, { maxWait }); } catch { return []; }
+ try {
+ const evs = await pool.querySync(this.relays, filter, { maxWait });
+ const oks = await verifyEventsAsync(evs);
+ return evs.filter((_, i) => oks[i]);
+ } catch { return []; }
}
// Live subscription; returns an unsubscribe function.
subscribeEvents(filter, onEvent) {
try {
// single filter object — see subscribeOn for the array-wrapping trap
- const sub = pool.subscribeMany(this.relays, filter, { onevent: onEvent });
+ const gated = (event) => { verifyOneAsync(event).then((ok) => { if (ok) onEvent(event); }); };
+ const sub = pool.subscribeMany(this.relays, filter, { onevent: gated });
return () => { try { sub.close(); } catch {} };
} catch { return () => {}; }
}
diff --git a/src/verify-worker.js b/src/verify-worker.js
new file mode 100644
index 0000000..679c1a8
--- /dev/null
+++ b/src/verify-worker.js
@@ -0,0 +1,19 @@
+// Off-main-thread nostr event verification. Schnorr signature checks (real
+// secp256k1) on a relay backlog blocked the UI on boot / when a community
+// opens — the carousel-stutter cause a CPU profile pinned to verifyEvent. This
+// worker does that work on another thread; the main thread posts events and
+// gets back a boolean per event, so the page never freezes on a burst.
+//
+// Bundled to dist/verify-worker.js (see build.js) and loaded as a classic
+// Worker. Same verifyEvent nostr-tools uses on the main thread, so semantics
+// are identical — this only moves WHERE it runs.
+import { verifyEvent } from 'nostr-tools/pure';
+
+self.onmessage = (e) => {
+ const { reqId, events } = e.data || {};
+ if (reqId == null || !Array.isArray(events)) return;
+ const results = events.map((ev) => {
+ try { return verifyEvent(ev); } catch { return false; }
+ });
+ self.postMessage({ reqId, results });
+};