aboutsummaryrefslogblamecommitdiffstats
path: root/HANDOFF.md
blob: 0fd13cef9b960626dc00ddbb7990971fa1e291ae (plain) (tree)
1
2
3
4
5



                                                       
                                                                     





































































































                                                                                               
                               















































































































































                                                                                                                                                     
 */
.highlight .vc { color: #369 } /* Name.Variable.Class */
.highlight .vg { color: #D70 } /* Name.Variable.Global */
.highlight .vi { color: #33B } /* Name.Variable.Instance */
.highlight .vm { color: #369 } /* Name.Variable.Magic */
.highlight .il { color: #00D; font-weight: bold } /* Literal.Number.Integer.Long */
// 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) };
}