aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-07 21:27:40 -0700
committerAdam Soltys <[email protected]>2026-10-07 21:27:40 -0700
commit654e489cb438c62c09d291cc781707323914f8f1 (patch)
tree70720196609718e2c8b651ceeb9d350948044c1f /src
parent6dfb59316865093eda01307ee3c95f87511cdf56 (diff)
downloadcoinosv3-654e489cb438c62c09d291cc781707323914f8f1.tar.gz
coinosv3-654e489cb438c62c09d291cc781707323914f8f1.zip
Gentler on relays: a relay that says rate-limited (CLOSED/OK/NOTICE) gets no one-shot reads for 30 s, doubling to 10 min; zap/reply tallies flush at most every 2 s in one REQ (two filters) and the live #e sub swaps at most every 10 s; quoted-note lookups batch per relay over 150 ms — scrolling a 300-follow feed went from ~21 to ~8.5 REQs per 10 s on the busiest relay, primal's "too many concurrent REQs" from 19 to 0
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01PCGjEEMD23CweKrefbTDJp
Diffstat (limited to 'src')
-rw-r--r--src/features/messages.js69
-rw-r--r--src/nostr.js91
2 files changed, 148 insertions, 12 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index d4eec34..53f6c99 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -13,7 +13,7 @@
// listens for both identities and decrypts with whichever keys are present.
import {
- subscribeOn, publishOn, queryOn, queryStreamOn, fetchInboxRelays, relayAlive, liveRelayList, resetRelay,
+ subscribeOn, publishOn, queryOn, queryManyOn, queryStreamOn, fetchInboxRelays, relayAlive, liveRelayList, readRelayList, resetRelay,
npubOf, neventOf, parseNostrPubkey, parseNostrRef, generateSecretKey, getPublicKey, finalizeEvent, nip44,
PROFILE_RELAYS, openWrapsOffthread, unwrapDMsOffthread, verifyEventsAsync, setSyncConfig, DEFAULT_SYNC_RELAYS,
} from '../nostr.js';
@@ -3841,6 +3841,12 @@ export function messagesFeature(ctx) {
const zapWho = new Map(); // id -> Map(receipt id -> { pk, sats, ts, text }) — the tally, by person
const zapAsked = new Set();
let zapQueue = new Set(), zapTimer = null, zapLiveUnsub = null, zapRecent = [];
+ // Scrolling brings new ids every few hundred ms; asking (two queries) and
+ // swapping the live sub (CLOSE + REQ) per batch put ~4 REQs a second on
+ // every zap relay. Flush at most every 2 s, swap the live sub at most
+ // every 10 s — the tallies still land within a couple of seconds.
+ let zapLastFlush = 0, zapLiveAt = 0, zapLiveTimer = null;
+ const ZAP_FLUSH_MS = 2000, ZAP_LIVE_MS = 10_000;
const zapRelays = () => [...new Set([...NOTE_RELAYS, ...((wallet.nostrRelays && wallet.nostrRelays()) || [])])];
function noteCountSnapshot(id) {
const rm = reacts.get(id);
@@ -3981,14 +3987,15 @@ export function messagesFeature(ctx) {
if (!fresh || zapTimer) return;
zapTimer = setTimeout(() => {
zapTimer = null;
+ zapLastFlush = Date.now();
const batch = [...zapQueue]; zapQueue = new Set();
const relays = zapRelays();
for (let i = 0; i < batch.length; i += 150) {
const slice = batch.slice(i, i + 150);
- queryOn(relays, { kinds: REPLY_KINDS, '#e': slice }, 4000).then((evs) => evs.forEach(noteReply)).catch(() => {});
- queryOn(relays, { kinds: NOTE_KINDS, '#e': slice }, 4000)
- .then((evs) => {
- evs.forEach(noteEvent);
+ // replies and tallies in one REQ (two filters), not one each
+ queryManyOn(relays, [{ kinds: REPLY_KINDS, '#e': slice }, { kinds: NOTE_KINDS, '#e': slice }], 4000)
+ .then((all) => {
+ for (const e of all) if (REPLY_KINDS.includes(e.kind)) noteReply(e); else noteEvent(e);
// the relays have spoken for these ids: their live tally is the
// truth now, remembered or not (a zap that was deleted has to be
// able to disappear)
@@ -4004,9 +4011,13 @@ export function messagesFeature(ctx) {
}).catch(() => {});
}
zapRecent = [...batch.reverse(), ...zapRecent.filter((x) => !batch.includes(x))].slice(0, 200);
- if (zapLiveUnsub) { try { zapLiveUnsub(); } catch {} }
- zapLiveUnsub = subscribeOn(relays, { kinds: [...NOTE_KINDS, ...REPLY_KINDS], '#e': zapRecent, since: Math.floor(Date.now() / 1000) - 60 }, noteEvent);
- }, 250);
+ if (!zapLiveTimer) zapLiveTimer = setTimeout(() => {
+ zapLiveTimer = null;
+ zapLiveAt = Date.now();
+ if (zapLiveUnsub) { try { zapLiveUnsub(); } catch {} }
+ zapLiveUnsub = subscribeOn(zapRelays(), { kinds: [...NOTE_KINDS, ...REPLY_KINDS], '#e': zapRecent, since: Math.floor(Date.now() / 1000) - 60 }, noteEvent);
+ }, Math.max(0, zapLiveAt + ZAP_LIVE_MS - Date.now()));
+ }, Math.max(250, zapLastFlush + ZAP_FLUSH_MS - Date.now()));
}
// Tallies are asked of the relays again on every boot, and that takes
// seconds — long enough that a room full of zapped messages paints bare and
@@ -6973,11 +6984,48 @@ export function messagesFeature(ctx) {
const WIDE_RELAYS = ['wss://nostr.mom', 'wss://relay.ditto.pub', 'wss://relay.nostrplebs.com', 'wss://nostr21.com',
'wss://nostr.wine', 'wss://relay.snort.social', 'wss://offchain.pub', 'wss://nostr.oxtr.dev',
'wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net'];
+ // Lookups wait 150 ms and go out together: one REQ per relay carrying
+ // every id wanted there, not one per quoted note (a scrolled feed sent a
+ // dozen relays a fresh REQ for each quote it met).
+ const findQ = new Map(); // relay -> Map(id -> waiters)
+ let findTimer = null;
+ function askNote(relays, id) {
+ relays = readRelayList(relays);
+ if (!relays.length) return Promise.resolve(null);
+ return new Promise((resolve) => {
+ const w = { left: relays.length, resolve, done: false };
+ for (const r of relays) {
+ let q = findQ.get(r);
+ if (!q) findQ.set(r, (q = new Map()));
+ if (!q.has(id)) q.set(id, []);
+ q.get(id).push(w);
+ }
+ if (!findTimer) findTimer = setTimeout(flushFind, 150);
+ });
+ }
+ function flushFind() {
+ findTimer = null;
+ const batch = [...findQ]; findQ.clear();
+ for (const [relay, wanted] of batch) {
+ const ids = [...wanted.keys()];
+ for (let i = 0; i < ids.length; i += 100) {
+ const slice = ids.slice(i, i + 100);
+ queryOn([relay], { ids: slice }, 4500).catch(() => []).then((evs) => {
+ const got = new Map(evs.map((e) => [e.id, e]));
+ for (const id of slice) for (const w of wanted.get(id)) {
+ if (w.done) continue;
+ const ev = got.get(id);
+ if (ev) { w.done = true; w.resolve(ev); }
+ else if (--w.left === 0) { w.done = true; w.resolve(null); }
+ }
+ });
+ }
+ }
+ }
// All at once, first answer wins: asked in turn, a note on a far relay
// took longer than the feed waits for a row.
function findNote(ref) {
- const pick = (evs) => (evs || []).find((e) => e.id === ref.id) || null;
- const ask = (relays) => (relays.length ? queryOn(relays, { ids: [ref.id] }, 4500).then(pick).catch(() => null) : Promise.resolve(null));
+ const ask = (relays) => askNote(relays, ref.id);
const near = [...new Set([...(ref.relays || []), ...zapRelays()])];
const asks = [
ask(near),
@@ -13195,6 +13243,7 @@ export function messagesFeature(ctx) {
listsSynced = false;
if (zapLiveUnsub) { try { zapLiveUnsub(); } catch {} zapLiveUnsub = null; }
clearTimeout(zapTimer); zapTimer = null; zapQueue = new Set(); zapRecent = [];
+ clearTimeout(zapLiveTimer); zapLiveTimer = null; zapLiveAt = 0;
stopFeedWatch();
++feedWarmSession;
clearTimeout(feedWarmTimer); feedWarmTimer = null;
diff --git a/src/nostr.js b/src/nostr.js
index a3cab53..2ab0b05 100644
--- a/src/nostr.js
+++ b/src/nostr.js
@@ -150,6 +150,61 @@ export function markRelaySick(url, why) {
resetRelay(url);
}
const isSick = (u) => sick.has(relayKey(u));
+
+// ---- rate limits ----------------------------------------------------------
+// A relay that answers "rate-limited:" (a CLOSED on a REQ, an OK false on a
+// publish, or a NOTICE asking us to slow down) is up, just tired of us —
+// asking again at the same pace is how an IP ends up banned (nos.lol and
+// damus both did, 2026-10). So one-shot reads skip it for a while: 30 s the
+// first time, doubling while it keeps refusing (ten minutes at most), and a
+// quiet stretch forgives the count. Live subscriptions and publishes still go
+// to it — they're few, and dropping a DM inbox relay costs more than a REQ.
+const COOL_MS = 30_000;
+const COOL_MAX_MS = 10 * 60_000;
+const cooling = new Map(); // normalized url -> { until, n }
+const RATE_LIMITED = /rate.?limit|slow down|too (many|fast)|throttl/i;
+export function markRelayRateLimited(url, why) {
+ if (!url) return;
+ const n = relayKey(url);
+ const prev = cooling.get(n);
+ const now = Date.now();
+ // a strike long after the last cool-off starts over
+ const strikes = prev && now - prev.until < COOL_MAX_MS ? prev.n + 1 : 1;
+ if (prev && prev.until > now) return; // already cooling; one burst is one strike
+ const ms = Math.min(COOL_MAX_MS, COOL_MS * 2 ** (strikes - 1));
+ console.warn(`nostr: ${url} rate-limited us (${why}) — no queries for ${Math.round(ms / 1000)} s`);
+ cooling.set(n, { until: now + ms, n: strikes });
+}
+const isCooling = (u) => { const c = cooling.get(relayKey(u)); return !!c && c.until > Date.now(); };
+// Every relay the pool opens reports its refusals here: wrap its subscribe /
+// publish / NOTICE once, so every caller (ours and the pool's) is covered.
+const _ensureRelay = pool.ensureRelay.bind(pool);
+pool.ensureRelay = async (url, params) => {
+ const relay = await _ensureRelay(url, params);
+ if (relay && !relay._coinosRL) {
+ relay._coinosRL = true;
+ const notice = relay.onnotice;
+ relay.onnotice = (msg) => {
+ if (RATE_LIMITED.test(String(msg))) markRelayRateLimited(relay.url, String(msg));
+ try { notice?.call(relay, msg); } catch {}
+ };
+ const subscribe = relay.subscribe.bind(relay);
+ relay.subscribe = (filters, p = {}) => subscribe(filters, {
+ ...p,
+ onclose: (reason) => {
+ if (RATE_LIMITED.test(String(reason || ''))) markRelayRateLimited(relay.url, String(reason));
+ p.onclose?.(reason);
+ },
+ });
+ const publish = relay.publish.bind(relay);
+ relay.publish = (evt) => publish(evt).catch((e) => {
+ const msg = e?.message || String(e || '');
+ if (RATE_LIMITED.test(msg)) markRelayRateLimited(relay.url, msg);
+ throw e;
+ });
+ }
+ return relay;
+};
// Back online or back on screen: the home relays get a fresh chance at once
// (verdicts saved by older builds could still be hours long).
function forgiveHomeRelays() {
@@ -177,6 +232,12 @@ function liveRelays(list) {
const ok = (list || []).filter((u) => !isSick(u));
return ok.length ? ok : (list || []);
}
+// For one-shot reads: also leave out relays cooling off a rate limit. An
+// empty answer is fine here — a query nobody can take now just finds nothing.
+function readRelays(list) {
+ const live = liveRelays(list);
+ return live.filter((u) => !isCooling(u));
+}
// Dial a relay once in the background to learn whether it's reachable; a
// rejection here is the only reliable signal nostr-tools gives us.
const probed = new Set();
@@ -224,6 +285,8 @@ export function resetRelay(url) {
}
// the relays a subscription on this list actually dials (sick ones skipped)
export const liveRelayList = (list) => liveRelays(list);
+// ...and the ones a one-shot read goes to (rate-limited ones skipped too)
+export const readRelayList = (list) => readRelays(list);
export function subscribeOn(relays, filter, onEvent, extra = {}) {
try {
@@ -241,7 +304,31 @@ export function subscribeOn(relays, filter, onEvent, extra = {}) {
export async function queryOn(relays, filter, maxWait = 1500) {
probeRelays(relays);
try {
- const evs = await pool.querySync(liveRelays(relays), filter, { maxWait });
+ const live = readRelays(relays);
+ if (!live.length) return [];
+ const evs = await pool.querySync(live, filter, { maxWait });
+ const oks = await verifyEventsAsync(evs);
+ return evs.filter((_, i) => oks[i]);
+ } catch { return []; }
+}
+// Several filters in ONE REQ per relay (NIP-01 allows a list) — one
+// subscription against the relay's REQ budget instead of one per filter.
+export async function queryManyOn(relays, filters, maxWait = 1500) {
+ probeRelays(relays);
+ try {
+ const live = readRelays(relays);
+ if (!live.length || !filters.length) return [];
+ const evs = await new Promise((resolve) => {
+ const got = [];
+ let sub = null, done = false;
+ const finish = () => { if (!done) { done = true; resolve(got); } };
+ sub = pool.subscribeMap(live.flatMap((url) => filters.map((filter) => ({ url, filter }))), {
+ maxWait,
+ onevent: (e) => got.push(e),
+ oneose: () => { try { sub?.close('closed automatically on eose'); } catch {} finish(); },
+ onclose: finish,
+ });
+ });
const oks = await verifyEventsAsync(evs);
return evs.filter((_, i) => oks[i]);
} catch { return []; }
@@ -252,7 +339,7 @@ export async function queryStreamOn(relays, filter, onEvent, maxWait = 1500) {
probeRelays(relays);
const pending = [];
try {
- const live = liveRelays(relays);
+ const live = readRelays(relays);
if (!live.length) return;
await new Promise((resolve) => {
pool.subscribeManyEose(live, filter, {