diff options
Diffstat (limited to 'src/negentropy.js')
| -rw-r--r-- | src/negentropy.js | 197 |
1 files changed, 197 insertions, 0 deletions
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) }; +} |
