From ed3c047bf3969f81488a2cf9cb52af009407f07f Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Fri, 16 Oct 2026 21:31:06 -0700 Subject: Negentropy (NIP-77): DM and notification catch-up fetch only what this device lacks. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A relay that speaks it compares the ids it holds for a filter with the ids we hold and answers with the difference — a few hundred bytes, one round — instead of re-serving its newest events every time we ask. Measured on relay.coinos.io: the DM subscription re-served 200 gift wraps (468 KB) on every boot and every resubscribe, the Notifications refresh 120 events (105 KB) a visit; a reconcile holding the newest 100 of 120 found the 20 missing in 1.8 KB and 41 ms. src/negentropy.js: the protocol (version 1, as strfry speaks it), a port of Doug Hoyte's reference client (MIT): NegSet of [created_at, id] items, Negentropy.initiate / reconcile → { next, have, need }, WebCrypto SHA-256. Byte-identical opening message to the reference's; seven pairings of ours and theirs as client and relay agreed on every have/need. src/nostr.js: reconcileOn(url, filter, items) on its own socket (the pool's parser has no ear for NEG-* frames) → { need, have } or null; a relay that doesn't speak it (NOTICE "negentropy disabled", CLOSED, a socket shut on the opening message, silence) is remembered for a day in coinos-neg-off, so the plain query goes straight out there next time; NEG-ERR blocked/closed is not remembered. syncOn(relays, filter, items): reconciliation where it works, the plain query where it doesn't, the ids we lack fetched by id in slices, each event once, verified. messages.js — DMs: relay by relay, one that speaks it gets the live subscription with limit 0 (opened first, so nothing slips between) and a reconcile of its newest 400 against wrapItems (the wraps held, full id and time, 600 kept beside the 16-char prefix log; a wrap the prefix log knows but the list doesn't is added as the relay re-serves it, once); nos.lol, with negentropy disabled, keeps the plain subscription with its backlog, and so does every relay while the one-time heal pass runs. Notifications: the refresh reconciles the relay's newest 120 against notifHeld (every notification event seen, 300 kept — not just the rows, or a folded zap twin would come back every visit), persisted beside the rows; the watch is unchanged. Not applied to feeds: the outbox relays mostly lack it (nos.lol, damus, primal, nostr.wine, nostr.mom, nostrplebs say no; relay.coinos.io, purplepag.es, ditto, oxtr, nostr21, offchain yes) and a first older page has nothing to reconcile against. Tests: tools/negentropy-test.js (the reference's bytes, one round for a small difference, eight for a 4 KB frame, nothing to say when equal); tools/negentropy-sync-test.js (an in-page relay speaking NEG with the library on its side: the wrap subscription opens limit 0 and the three wraps lacked are fetched by id, nos.lol's "disabled" gets the plain subscription and is remembered, the boot refresh fetches the three notifications by id and the page's refresh and a refresh after reload fetch nothing). notif-report-fold, notif-faces, dm-filter, dm-row-resort, dm-nip42 pass. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01J1Kon8bxJoHRpJdSzshCkp --- src/features/messages.js | 125 ++++++++++++++++++++++++++---- src/negentropy.js | 197 +++++++++++++++++++++++++++++++++++++++++++++++ src/nostr.js | 94 ++++++++++++++++++++++ 3 files changed, 399 insertions(+), 17 deletions(-) create mode 100644 src/negentropy.js (limited to 'src') diff --git a/src/features/messages.js b/src/features/messages.js index a40f7d5..ad5c32c 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -17,6 +17,7 @@ import { npubOf, neventOf, parseNostrPubkey, parseNostrRef, generateSecretKey, getPublicKey, finalizeEvent, nip44, PROFILE_RELAYS, openWrapsOffthread, unwrapDMsOffthread, verifyEventsAsync, setSyncConfig, DEFAULT_SYNC_RELAYS, } from '../nostr.js'; +import { reconcileOn, syncOn, negentropyOff } from '../nostr.js'; // NIP-77: fetch only what we lack import { channelKey, channelStream, channelEpoch, channelIsPrivate, controlKey, guestbookKey, openWrap, wrapRumor, rumorWithId, foldControl, foldGuestbook, observeAuthor, eventMs, msTags, makeEdition, @@ -688,23 +689,57 @@ export function messagesFeature(ctx) { healSince = 0; } const healing = (wrap) => !!healSince && wrap.created_at >= healSince && !healed.has(wrap.id) && !!healed.add(wrap.id); - function rememberWrap(id) { - const k = wrapKey(id); - if (!wrapLog) wrapLog = []; - if (!seenWraps.has(k)) wrapLog.push(k); - seenWraps.add(k); + // The wraps this device holds — full id and time — for reconciliation + // (NIP-77) with a relay that speaks it: a subscribe then fetches only the + // wraps we lack instead of the relay's newest two hundred (half a + // megabyte, every boot and every resubscribe), and the live subscription + // opens with limit 0, nothing re-served. The prefix log above stays the + // "skip this one" memory; a wrap it knows but this list doesn't (logged + // before the list existed) is added as the relay re-serves it, once. + const WRAP_ITEMS_MAX = 600; // the relay reconciles its newest 400: that many held, with room (46 KB of a phone's near-full localStorage) + let wrapItems = null; // id -> created_at, oldest first + function loadWrapItems() { + if (wrapItems) return; + wrapItems = new Map(); + try { for (const [id, ts] of st().wrapItems || []) if (/^[0-9a-f]{64}$/.test(id)) wrapItems.set(id, ts | 0); } catch {} + } + function noteWrapItem(id, ts) { + if (!id || !ts) return; + loadWrapItems(); + if (wrapItems.has(id)) return; + wrapItems.set(id, ts); + while (wrapItems.size > WRAP_ITEMS_MAX) wrapItems.delete(wrapItems.keys().next().value); + scheduleWrapSave(); + } + const wrapItemsHeld = () => { loadWrapItems(); return [...wrapItems].map(([id, ts]) => [ts, id]); }; + function scheduleWrapSave() { clearTimeout(wrapsTimer); wrapsTimer = setTimeout(() => { try { // another tab may have logged wraps since this one loaded: keep theirs const s = st(); - const mine = new Set(wrapLog); - s.wraps = [...(s.wraps || []).filter((k) => !mine.has(k)), ...wrapLog].slice(-WRAPS_MAX); - wrapLog = s.wraps; + if (wrapLog) { + const mine = new Set(wrapLog); + s.wraps = [...(s.wraps || []).filter((k) => !mine.has(k)), ...wrapLog].slice(-WRAPS_MAX); + wrapLog = s.wraps; + } + if (wrapItems) { + const all = new Map(s.wrapItems || []); + for (const [id, ts] of wrapItems) all.set(id, ts); + s.wrapItems = [...all].slice(-WRAP_ITEMS_MAX); + } save(s); } catch {} }, 1500); } + function rememberWrap(id, ts) { + const k = wrapKey(id); + if (!wrapLog) wrapLog = []; + if (!seenWraps.has(k)) wrapLog.push(k); + seenWraps.add(k); + if (ts) noteWrapItem(id, ts); + scheduleWrapSave(); + } let dmStarted = false; let allUnsubs = []; let dmUnsubs = []; // the DM wrap subscriptions alone, so they can be rebuilt @@ -3055,13 +3090,14 @@ export function messagesFeature(ctx) { async function handleInboxWrap(wrap) { let heal = false; if (seenWraps.has(wrap.id) || seenWraps.has(wrapKey(wrap.id))) { + noteWrapItem(wrap.id, wrap.created_at); // known by its prefix alone: now held in full if (!healing(wrap)) return; heal = true; } seenWraps.add(wrap.id); const r = await openInboxWrap(wrap, heal); if (r === 'stale') return; - if (r === true) { rememberWrap(wrap.id); return; } + if (r === true) { rememberWrap(wrap.id, wrap.created_at); return; } if (pendingWraps.size >= PENDING_MAX) pendingWraps.delete(pendingWraps.keys().next().value); pendingWraps.set(wrap.id, { wrap, heal, tries: r !== 'later' && decryptorsComplete() ? 1 : 0 }); scheduleDrain(8000); @@ -3101,9 +3137,9 @@ export function messagesFeature(ctx) { if (complete && !decryptorsComplete()) break; const r = await openInboxWrap(p.wrap, p.heal); if (r === 'stale') break; // the account changed under this pass - if (r === true) { pendingWraps.delete(id); rememberWrap(id); } + if (r === true) { pendingWraps.delete(id); rememberWrap(id, p.wrap && p.wrap.created_at); } else if (r === 'later') break; // the signer went quiet — nothing learned, try the rest later - else if (complete && ++p.tries >= PENDING_TRIES) { pendingWraps.delete(id); rememberWrap(id); } + else if (complete && ++p.tries >= PENDING_TRIES) { pendingWraps.delete(id); rememberWrap(id, p.wrap && p.wrap.created_at); } if (performance.now() - chunkStart > 8) { await new Promise((r) => setTimeout(r)); chunkStart = performance.now(); @@ -3160,12 +3196,42 @@ export function messagesFeature(ctx) { } bumpMsgRev(); if (swept) save(s); - const dmSub = (relays) => { - const u = subscribeOn(relays, { kinds: [1059], '#p': pks, limit: 400 }, (wrap) => { - handleInboxWrap(wrap).catch(() => {}); - }, { onclose: noteSubClosed }); + const onWrap = (wrap) => { handleInboxWrap(wrap).catch(() => {}); }; + const plainDmSub = (relays) => { + const u = subscribeOn(relays, { kinds: [1059], '#p': pks, limit: 400 }, onWrap, { onclose: noteSubClosed }); allUnsubs.push(u); dmUnsubs.push(u); }; + // Relay by relay. One that speaks negentropy gets the live subscription + // with limit 0 — opened first, so nothing slips between — and a + // reconciliation of the wraps we hold against its newest four hundred, + // the ones we lack fetched by id. One that doesn't (nos.lol has it + // disabled; remembered for a day) is asked the old way, its newest + // wraps re-served and skipped by the prefix log. So is every relay + // while the one-time heal is running: that pass wants them re-served. + const dmSub = (relays) => { + for (const relay of relays) { + if (healSince || negentropyOff(relay)) { plainDmSub([relay]); continue; } + const u = subscribeOn([relay], { kinds: [1059], '#p': pks, limit: 0 }, onWrap, { onclose: noteSubClosed }); + allUnsubs.push(u); dmUnsubs.push(u); + reconcileWraps(relay).then((ok) => { + if (ok || !dmStarted || !dmUnsubs.includes(u)) return; + // it doesn't speak it after all: the plain subscription, with its backlog + try { u(); } catch {} + allUnsubs = allUnsubs.filter((x) => x !== u); dmUnsubs = dmUnsubs.filter((x) => x !== u); + plainDmSub([relay]); + }).catch(() => {}); + } + }; + async function reconcileWraps(relay) { + const r = await reconcileOn(relay, { kinds: [1059], '#p': pks, limit: 400 }, wrapItemsHeld()); + if (!r) return false; + dlog('dm: ' + relay + ' reconciled: ' + r.need.length + ' wraps to fetch, ' + r.have.length + ' held it lacks'); + for (let i = 0; i < Math.min(r.need.length, 600) && dmStarted; i += 100) { + const evs = await queryOn([relay], { ids: r.need.slice(i, i + 100) }, 6000); + for (const w of evs) onWrap(w); + } + return true; + } dmSub(DM_RELAYS); // Senders deliver to the relays our kind-10050 advertises — a list other // clients may have published with relays beyond our defaults. Read those @@ -15092,6 +15158,28 @@ export function messagesFeature(ctx) { let notif = null; // { status, items } const NOTIF_V = 2; let notifAt = 0, notifUnsub = null; + // Every notification event seen, id and time, for reconciliation (NIP-77) + // with the relays that speak it: a refresh then fetches only what's new, + // not the newest hundred and twenty again — a hundred kilobytes a visit. + // All of them, not the rows kept: a zap receipt folded into its twin, or + // a hidden author's reply, would otherwise come back on every refresh. + const NOTIF_SEEN_MAX = 300; // the relay reconciles its newest 120 + let notifHeld = null; // id -> created_at, oldest first + function notifHeldItems() { + if (!notifHeld) { + notifHeld = new Map(); + try { const s = st(); if (s.notifHeldPk === identityPk()) for (const [id, ts] of s.notifHeld || []) notifHeld.set(id, ts | 0); } catch {} + for (const x of notifNow().items) if (x && x.id && x.ts && !notifHeld.has(x.id)) notifHeld.set(x.id, x.ts); + } + return [...notifHeld].map(([id, ts]) => [ts, id]); + } + function noteNotifHeld(evs) { + notifHeldItems(); + let grew = false; + for (const ev of evs || []) if (ev && ev.id && !notifHeld.has(ev.id)) { notifHeld.set(ev.id, ev.created_at | 0); grew = true; } + while (notifHeld.size > NOTIF_SEEN_MAX) notifHeld.delete(notifHeld.keys().next().value); + return grew; + } const notifNoteAsking = new Set(); const notifSeen = () => st().notifSeen || 0; // Notifications are about the IDENTITY, not the wallet: the stored list @@ -15151,6 +15239,7 @@ export function messagesFeature(ctx) { for (const id of wanted) if (notifNotes.has(id)) notes[id] = savedNotifNote(notifNotes.get(id)); const s2 = st(); s2.notifs = notif.items; s2.notifsPk = identityPk(); s2.notifsV = NOTIF_V; + if (notifHeld) { s2.notifHeld = [...notifHeld]; s2.notifHeldPk = identityPk(); } s2.notifNoteCache = notes; s2.notifNotesPk = identityPk(); save(s2); } @@ -15263,9 +15352,11 @@ export function messagesFeature(ctx) { notifAt = Date.now(); const g = session; try { - const evs = await queryOn(zapRelays(), { kinds: NOTIF_KINDS, '#p': my, limit: 120 }, 5000); + const evs = await syncOn(zapRelays(), { kinds: NOTIF_KINDS, '#p': my, limit: 120 }, notifHeldItems(), { maxWait: 5000 }); if (g !== session) return; // asked for the previous account + const grew = noteNotifHeld(evs); if (mergeNotifs(evs)) scheduleRepaint(); + if (grew) persistNotifs(); } catch {} finally { if (g === session) { if (notif) notif.status = 'ready'; @@ -15817,7 +15908,7 @@ export function messagesFeature(ctx) { noteCountsReady.clear(); // what happened to THEIR posts stays with them — the next identity // starts its list empty and asks the relays under its own key - stopNotifWatch(); notif = null; notifAt = 0; notifNotes.clear(); + stopNotifWatch(); notif = null; notifHeld = null; notifAt = 0; notifNotes.clear(); session++; zapWho.clear(); whoOpenIds.clear(); clearTimeout(zapSaveT); zapSaveT = null; zapSeed = null; diff --git a/src/negentropy.js b/src/negentropy.js new file mode 100644 index 0000000..83f65ae --- /dev/null +++ b/src/negentropy.js @@ -0,0 +1,197 @@ +// Negentropy (NIP-77): set reconciliation with a relay. The client holds a +// set of (created_at, id) pairs — the events it already has for a filter — +// and the relay holds its own. Instead of the relay re-sending every event +// the filter matches (a DM catch-up re-served 200 gift wraps, half a +// megabyte, on every subscribe; a notification refresh a hundred kilobytes), +// the two sides exchange fingerprints of ranges of their sets, split the +// ranges that differ, and end with the ids each side lacks — a few hundred +// bytes, usually in one round. The events themselves are then fetched by id. +// +// Protocol version 1 (0x61), as strfry speaks it. A port of Doug Hoyte's +// reference client (github.com/hoytech/negentropy, MIT, © 2023), trimmed to +// the initiator's side plus what a test needs to play the relay. +// Hashing is WebCrypto SHA-256, so everything here is async. + +const PROTOCOL_VERSION = 0x61; +const ID_SIZE = 32; +const FINGERPRINT_SIZE = 16; +const MODE = { skip: 0, fingerprint: 1, idList: 2 }; +const MAX_TS = Number.MAX_SAFE_INTEGER; // "infinity": the bound past every item + +// ---- bytes ---------------------------------------------------------------- +const hexOf = (a) => { let s = ''; for (let i = 0; i < a.length; i++) s += (a[i] < 16 ? '0' : '') + a[i].toString(16); return s; }; +const bytesOf = (h) => { if (h instanceof Uint8Array) return h; if (h.length % 2) throw new Error('odd hex'); const a = new Uint8Array(h.length / 2); for (let i = 0; i < a.length; i++) a[i] = parseInt(h.substr(i * 2, 2), 16); return a; }; +const cmpBytes = (a, b) => { const n = Math.min(a.length, b.length); for (let i = 0; i < n; i++) { if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1; } return a.length === b.length ? 0 : a.length < b.length ? -1 : 1; }; +const cmpItem = (a, b) => (a.ts === b.ts ? cmpBytes(a.id, b.id) : a.ts < b.ts ? -1 : 1); + +class Out { + constructor() { this.parts = []; this.length = 0; } + push(a) { if (Array.isArray(a)) a = Uint8Array.from(a); this.parts.push(a); this.length += a.length; } + bytes() { const o = new Uint8Array(this.length); let p = 0; for (const a of this.parts) { o.set(a, p); p += a.length; } return o; } +} +class In { + constructor(a) { this.a = a; this.p = 0; } + get left() { return this.a.length - this.p; } + byte() { if (this.p >= this.a.length) throw new Error('negentropy: message ends early'); return this.a[this.p++]; } + take(n) { if (this.p + n > this.a.length) throw new Error('negentropy: message ends early'); const s = this.a.subarray(this.p, this.p + n); this.p += n; return s; } +} +// varints, big-endian 7-bit groups with a continuation bit (the protocol's) +const putVar = (o, n) => { if (n === 0) return o.push([0]); const g = []; while (n > 0) { g.push(n % 128); n = Math.floor(n / 128); } g.reverse(); for (let i = 0; i < g.length - 1; i++) g[i] |= 128; o.push(g); }; +const getVar = (i) => { let n = 0; for (;;) { const b = i.byte(); n = n * 128 + (b & 127); if (!(b & 128)) return n; } }; + +// ---- the fingerprint of a range: the ids summed mod 2^256, hashed with the count +const sha256 = async (bytes) => new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)); +function addInto(acc, id) { + let carry = 0; + for (let i = 0; i < ID_SIZE; i++) { const s = acc[i] + id[i] + carry; acc[i] = s & 255; carry = s >> 8; } +} +async function fingerprint(items, begin, end) { + const acc = new Uint8Array(ID_SIZE); + for (let i = begin; i < end; i++) addInto(acc, items[i].id); + const o = new Out(); o.push(acc); putVar(o, end - begin); + return (await sha256(o.bytes())).subarray(0, FINGERPRINT_SIZE); +} + +// ---- the set ---------------------------------------------------------------- +// `items`: [[created_at, id hex], ...] in any order; duplicates are dropped. +export class NegSet { + constructor(items = []) { + const seen = new Set(); + this.items = []; + for (const [ts, id] of items) { + if (typeof id !== 'string' || id.length !== 64 || seen.has(id)) continue; + seen.add(id); + this.items.push({ ts: Math.max(0, Math.floor(ts || 0)), id: bytesOf(id) }); + } + this.items.sort(cmpItem); + } + get size() { return this.items.length; } + // the first index in [begin, end) at or past the bound + lowerBound(begin, end, bound) { + let lo = begin, hi = end; + while (lo < hi) { const m = (lo + hi) >> 1; if (cmpItem(this.items[m], bound) < 0) lo = m + 1; else hi = m; } + return lo; + } +} + +// ---- one side of a reconciliation ------------------------------------------ +export class Negentropy { + constructor(set, { frameSizeLimit = 0 } = {}) { + if (frameSizeLimit && frameSizeLimit < 4096) throw new Error('negentropy: frame size limit too small'); + this.set = set; + this.frameSizeLimit = frameSizeLimit; + this.initiator = false; + this.lastTsIn = 0; this.lastTsOut = 0; + } + // the client's opening message (hex) + async initiate() { + this.initiator = true; + const o = new Out(); o.push([PROTOCOL_VERSION]); + await this.splitRange(0, this.set.size, { ts: MAX_TS, id: new Uint8Array(0) }, o); + return hexOf(o.bytes()); + } + // One message in, one out. For the initiator the answer is null once the + // sets are reconciled; `have` are ids we hold that the other side lacks, + // `need` the ones it holds that we lack. The non-initiator (a relay, or + // a test playing one) always answers and collects nothing. + async reconcile(hex) { + const q = new In(bytesOf(hex)); + const have = [], need = []; + this.lastTsIn = this.lastTsOut = 0; + const full = new Out(); full.push([PROTOCOL_VERSION]); + const version = q.byte(); + if (version < 0x60 || version > 0x6f) throw new Error('negentropy: not a negentropy message'); + if (version !== PROTOCOL_VERSION) { + if (this.initiator) throw new Error('negentropy: unsupported protocol version ' + (version - 0x60)); + return { next: hexOf(full.bytes()), have, need }; + } + const items = this.set.items, size = this.set.size; + let prevBound = { ts: 0, id: new Uint8Array(0) }, prevIndex = 0, skip = false; + while (q.left) { + const o = new Out(); + const doSkip = () => { if (skip) { skip = false; this.putBound(o, prevBound); putVar(o, MODE.skip); } }; + const bound = this.getBound(q); + const mode = getVar(q); + const lower = prevIndex; + let upper = this.set.lowerBound(prevIndex, size, bound); + if (mode === MODE.skip) skip = true; + else if (mode === MODE.fingerprint) { + const theirs = q.take(FINGERPRINT_SIZE); + const ours = await fingerprint(items, lower, upper); + if (cmpBytes(theirs, ours) !== 0) { doSkip(); await this.splitRange(lower, upper, bound, o); } + else skip = true; + } else if (mode === MODE.idList) { + const n = getVar(q); + const theirs = new Map(); + for (let i = 0; i < n; i++) { const id = q.take(ID_SIZE); if (this.initiator) theirs.set(hexOf(id), id); } + if (this.initiator) { + skip = true; + for (let i = lower; i < upper; i++) { + const k = hexOf(items[i].id); + if (theirs.has(k)) theirs.delete(k); else have.push(k); + } + for (const k of theirs.keys()) need.push(k); + } else { + doSkip(); + const ids = new Out(); let count = 0, endBound = bound; + for (let i = lower; i < upper; i++) { + if (this.overFrame(full.length + ids.length)) { endBound = items[i]; upper = i; break; } + ids.push(items[i].id); count++; + } + this.putBound(o, endBound); putVar(o, MODE.idList); putVar(o, count); o.push(ids.bytes()); + full.push(o.bytes()); o.parts = []; o.length = 0; + } + } else throw new Error('negentropy: unexpected mode ' + mode); + if (this.overFrame(full.length + o.length)) { + // the frame is full: one fingerprint for everything left + const rest = await fingerprint(items, upper, size); + this.putBound(full, { ts: MAX_TS, id: new Uint8Array(0) }); putVar(full, MODE.fingerprint); full.push(rest); + break; + } + full.push(o.bytes()); + prevIndex = upper; prevBound = bound; + } + return { next: full.length === 1 && this.initiator ? null : hexOf(full.bytes()), have, need }; + } + // a range that differs: sixteen fingerprinted buckets, or the ids outright when small + async splitRange(lower, upper, upperBound, o) { + const items = this.set.items, n = upper - lower, buckets = 16; + if (n < buckets * 2) { + this.putBound(o, upperBound); putVar(o, MODE.idList); putVar(o, n); + for (let i = lower; i < upper; i++) o.push(items[i].id); + return; + } + const per = Math.floor(n / buckets), extra = n % buckets; + let cur = lower; + for (let b = 0; b < buckets; b++) { + const size = per + (b < extra ? 1 : 0); + const fp = await fingerprint(items, cur, cur + size); + cur += size; + const next = cur === upper ? upperBound : minimalBound(items[cur - 1], items[cur]); + this.putBound(o, next); putVar(o, MODE.fingerprint); o.push(fp); + } + } + overFrame(n) { return !!this.frameSizeLimit && n > this.frameSizeLimit - 200; } + // bounds: a timestamp as a delta from the last one (+1; 0 = infinity), then a prefix of an id + getBound(q) { + let ts = getVar(q); + ts = ts === 0 ? MAX_TS : ts - 1; + if (this.lastTsIn === MAX_TS || ts === MAX_TS) { this.lastTsIn = MAX_TS; ts = MAX_TS; } + else { ts += this.lastTsIn; this.lastTsIn = ts; } + const len = getVar(q); + if (len > ID_SIZE) throw new Error('negentropy: bound key too long'); + return { ts, id: q.take(len) }; + } + putBound(o, b) { + if (b.ts === MAX_TS) { this.lastTsOut = MAX_TS; putVar(o, 0); } + else { putVar(o, b.ts - this.lastTsOut + 1); this.lastTsOut = b.ts; } + putVar(o, b.id.length); o.push(b.id); + } +} +// the shortest bound that separates two neighbouring items +function minimalBound(prev, cur) { + if (cur.ts !== prev.ts) return { ts: cur.ts, id: new Uint8Array(0) }; + let shared = 0; + while (shared < ID_SIZE && cur.id[shared] === prev.id[shared]) shared++; + return { ts: cur.ts, id: cur.id.subarray(0, shared + 1) }; +} diff --git a/src/nostr.js b/src/nostr.js index b76e865..8dcab01 100644 --- a/src/nostr.js +++ b/src/nostr.js @@ -18,6 +18,7 @@ import { normalizeURL } from 'nostr-tools/utils'; import { wrapEvent as nip17WrapEvent } from 'nostr-tools/nip17'; import { SimplePool } from 'nostr-tools/pool'; import { dlog } from './debug.js'; +import { NegSet, Negentropy } from './negentropy.js'; import { randomBytes, bytesToHex } from '@noble/hashes/utils'; import { base64urlnopad } from '@scure/base'; @@ -365,6 +366,99 @@ export async function queryOn(relays, filter, maxWait = 1500) { } // 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. +// ---- negentropy (NIP-77): fetch only what we lack --------------------------- +// A relay that speaks it (strfry with it enabled: relay.coinos.io does; +// nos.lol, damus and primal answer "negentropy disabled") compares the ids +// it holds for a filter with the ids we hold and tells us the difference — +// a few hundred bytes — instead of re-sending the newest N events every +// time we ask. The events we lack are then fetched by id. A relay that +// doesn't speak it is remembered for a day, so the plain query goes +// straight out there next time rather than after a wasted socket. +const NEG_OFF_KEY = 'coinos-neg-off'; +const NEG_OFF_MS = 24 * 60 * 60_000; +const negOff = new Map(); // normalized url -> until +try { + for (const [u, until] of Object.entries(JSON.parse(localStorage.getItem(NEG_OFF_KEY) || '{}'))) if (until > Date.now()) negOff.set(u, until); +} catch {} +function negRefused(url) { + negOff.set(relayKey(url), Date.now() + NEG_OFF_MS); + try { localStorage.setItem(NEG_OFF_KEY, JSON.stringify(Object.fromEntries([...negOff].slice(-100)))); } catch {} +} +export const negentropyOff = (url) => (negOff.get(relayKey(url)) || 0) > Date.now(); +// Reconcile `items` ([[created_at, id], ...]: the events we hold) with one +// relay under `filter`. Resolves { need, have } — the ids it holds that we +// don't, and ours it lacks — or null when it can't be done there: the relay +// doesn't speak it (a NOTICE, a CLOSED, a socket shut on the opening +// message, or silence — remembered for a day), refuses this query (NEG-ERR +// blocked/closed — not remembered), is sick, or we're offline. Its own +// socket: the pool's parser has no ear for NEG-* frames. +export async function reconcileOn(url, filter, items, { timeout = 8000 } = {}) { + const n = relayKey(url); + if (sick.has(n) || negentropyOff(url) || offline()) return null; + let ws; + try { ws = new WebSocket(url); } catch { return null; } + const neg = new Negentropy(new NegSet(items)); + const id = 'neg' + Math.random().toString(36).slice(2, 10); + return new Promise((resolve) => { + let done = false, heard = false; + const need = [], have = []; + const finish = (r, refused) => { + if (done) return; + done = true; clearTimeout(t); + if (refused) negRefused(url); + try { if (ws.readyState === 1) ws.send(JSON.stringify(['NEG-CLOSE', id])); } catch {} + try { ws.close(); } catch {} + resolve(r); + }; + const t = setTimeout(() => finish(null, !heard), timeout); + ws.onerror = () => finish(null, false); + ws.onclose = () => finish(null, !heard && !offline() && !hiddenTab()); + ws.onopen = async () => { + try { ws.send(JSON.stringify(['NEG-OPEN', id, filter, await neg.initiate()])); } catch { finish(null, false); } + }; + ws.onmessage = async (e) => { + let m; try { m = JSON.parse(e.data); } catch { return; } + if (!Array.isArray(m)) return; + if (m[0] === 'NEG-MSG' && m[1] === id) { + heard = true; + try { + const r = await neg.reconcile(m[2]); + need.push(...r.need); have.push(...r.have); + if (r.next) ws.send(JSON.stringify(['NEG-MSG', id, r.next])); + else finish({ need, have }, false); + } catch (err) { dlog('nostr: negentropy with ' + url + ' failed: ' + (err?.message || err)); finish(null, false); } + } else if (m[0] === 'NEG-ERR' && m[1] === id) { dlog('nostr: negentropy refused by ' + url + ': ' + m[2]); finish(null, false); } + else if (m[0] === 'NOTICE' && /negentropy|NEG-OPEN|bad msg/i.test(String(m[1] || ''))) finish(null, true); + else if (m[0] === 'CLOSED' && m[1] === id) finish(null, true); + }; + }); +} +// What `filter` matches on these relays, each event once, verified: by +// reconciliation where a relay speaks it (only the events we don't hold +// come, by id) and by the plain query where it doesn't. `items` are the +// events held, [[created_at, id], ...]. +export async function syncOn(relays, filter, items, { maxWait = 5000 } = {}) { + probeRelays(relays); + const live = readRelays(relays); + if (!live.length) return []; + const plain = [], byRelay = new Map(), seen = new Set(); + await Promise.all(live.map(async (url) => { + const r = await reconcileOn(url, filter, items).catch(() => null); + if (!r) { plain.push(url); return; } + for (const id of r.need) { + if (seen.has(id)) continue; // one copy is enough: the first relay that has it + seen.add(id); + if (!byRelay.has(url)) byRelay.set(url, []); + byRelay.get(url).push(id); + } + })); + const jobs = []; + if (plain.length) jobs.push(queryOn(plain, filter, maxWait)); + for (const [url, ids] of byRelay) for (let i = 0; i < Math.min(ids.length, 1000); i += 200) jobs.push(queryOn([url], { ids: ids.slice(i, i + 200) }, maxWait)); + const out = new Map(); + for (const evs of await Promise.all(jobs)) for (const e of evs || []) if (e && e.id && !out.has(e.id)) out.set(e.id, e); + return [...out.values()]; +} export async function queryManyOn(relays, filters, maxWait = 1500) { probeRelays(relays); try { -- cgit v1.2.3