OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / packages / services / builtin / poker / persistence-hypercore.js

362 lines · javascript ref:
/**
 * Hypercore-backed persistence for PokerApp.
 *
 * Wraps a PokerApp so that every successfully-appended SignedLog entry is
 * also appended to a per-table Hypercore. On startup, the adapter rehydrates
 * each table's in-memory SignedLog from its Hypercore's blocks.
 *
 * ─── Why a hypercore (and not raw files)? ──────────────────────────────────
 *
 * Three things drop out for free once entries live in a Hypercore:
 *
 *   1. The relay's existing seeder / federation / custody-pipeline picks up
 *      the core via its key. No extra plumbing.
 *   2. The cancellation-contract (reliability v2) guarantees apply — a
 *      relay claiming to pin a table's history can't lie about it.
 *   3. Cross-relay replication is the same code path used for every other
 *      seeded core, including the partial-pin auto-heal work.
 *
 * ─── Storage layout ────────────────────────────────────────────────────────
 *
 * One hypercore per table, accessed via the corestore namespace:
 *
 *   store.get({ name: 'poker/<tableKey>' })
 *
 * Each block is a single signed entry, JSON-encoded as utf-8. We don't use
 * any binary framing on top — the SignedLog format already covers
 * everything the relay needs and JSON keeps debugging trivial.
 *
 * ─── Block padding (size-blindness) ─────────────────────────────────────────
 *
 * The relay is card-blind — it never reads entry.payload. But the *size* of
 * each block still leaks: a fold, a raise, and a card-reveal-with-decryption-
 * shares produce JSON of very different lengths, so anyone watching the core
 * (it replicates across relays) can infer the action type and table tempo
 * from block sizes alone — traffic analysis on an otherwise-opaque substrate.
 *
 * To close that, each block is padded up to the next size bucket with trailing
 * ASCII spaces before append. This is deliberately a *whitespace* pad rather
 * than a framing change, which makes it free on every axis that matters:
 *
 *   - Signature-transparent: the ed25519 signature covers only the canonical
 *     entry fields (tableKey/writer/seq/ts/payload). Padding lives entirely
 *     outside the JSON object, so it never touches what was signed.
 *   - Reader-transparent: JSON.parse ignores trailing whitespace, so the
 *     replay path (and any client) parses a padded block identically to an
 *     unpadded one — no decode change, no entry pollution.
 *   - Backward-compatible: legacy un-padded blocks parse exactly as before,
 *     so a core written by an older relay replays without migration.
 *
 * Common betting actions (fold/check/call/bet/raise) all fall in the smallest
 * bucket and become mutually indistinguishable by size. The default ladder is
 * DEFAULT_PAD_BUCKETS; pass `padding: false` to opt out (legacy raw JSON) or
 * `padding: [n, …]` for a custom ascending ladder. Timing-channel
 * decorrelation (jittering append order) is intentionally NOT done here —
 * appends must stay strictly ordered, so that belongs in a serialized queue,
 * a separate follow-up.
 *
 * ─── Table provisioning ────────────────────────────────────────────────────
 *
 *   await persistence.createPersistentTable({ tableKey, writers, options })
 *
 * If a backing hypercore exists, its blocks are replayed into a fresh
 * SignedLog via PokerApp.replayEntries (which calls SignedLog._replay —
 * the trust-at-hydrate path). New entries then go through the normal
 * cryptographic check before being appended both to the in-memory log
 * and to the core. We never bypass validation on the live path — only
 * on the rehydrate-from-store path, and the store IS our own append
 * sink, so the entries it returns are entries we ourselves accepted.
 *
 * ─── What this is NOT ──────────────────────────────────────────────────────
 *
 * Not autobase. Not multi-writer in the corestore sense — one core per
 * table, the relay is the only one appending. The "multi-writer" property
 * of a poker table comes from multiple ed25519-signed entries within that
 * single linear core. (Autobase would be the right tool for a model where
 * every player has their own writer core and the table is a merge — that's
 * a bigger refactor for a follow-up.)
 *
 * ─── Failure modes ─────────────────────────────────────────────────────────
 *
 * If the core append fails after the in-memory append succeeded, the log is
 * ahead of disk. We emit a `mirror-error` event and continue — the in-memory
 * log is the source of truth for live play; missing-from-disk entries would
 * be re-published by clients on reconnect via re-submission (signed-log
 * idempotency: same writer + same seq = BAD_SEQ on the in-memory log so the
 * client knows it's already accepted, but the core append can be retried by
 * the operator's reconciliation pass — out of scope here).
 */

import { EventEmitter } from 'events'

const CORE_NAME_PREFIX = 'poker/'
const APPEND_TIMEOUT_MS = 5000

/**
 * Default block-size ladder for padding (bytes). A block is padded up to the
 * smallest bucket >= its length. Chosen so every common betting action lands
 * in the first bucket (and is therefore size-indistinguishable), while the
 * larger buckets absorb shuffle proofs / decryption-share reveals without
 * rounding everything up to the 64 KiB MAX_ENTRY_BYTES ceiling. Blocks larger
 * than the top bucket are rounded up to the next multiple of it.
 */
const DEFAULT_PAD_BUCKETS = Object.freeze([1024, 4096, 16384, 65536])

export class HypercorePersistence extends EventEmitter {
  /**
   * @param {object} opts
   * @param {object} opts.pokerApp                A started PokerApp.
   * @param {object} opts.store                   A Corestore.
   * @param {(label, info) => void} [opts.log]    Optional logger.
   * @param {false|number[]} [opts.padding]       Block-size padding ladder.
   *   Omitted/true → DEFAULT_PAD_BUCKETS (on). `false` → disabled (legacy raw
   *   JSON). An ascending positive-integer array → custom buckets. See the
   *   "Block padding" section in the module header.
   */
  constructor (opts) {
    super()
    if (!opts || !opts.pokerApp) throw new Error('HypercorePersistence: pokerApp required')
    if (!opts.store) throw new Error('HypercorePersistence: store required')
    this.pokerApp = opts.pokerApp
    this.store = opts.store
    this._log = opts.log || (() => {})
    /** @type {?number[]} Ascending pad buckets, or null when padding disabled. */
    this._padBuckets = _resolvePadBuckets(opts.padding)
    /** @type {Map<string, { core: object, unsub: () => void }>} */
    this._mirrors = new Map()
    this._stopped = false
  }

  /**
   * Returns the core for a given table key, opening it if needed.
   * Idempotent — same key returns the same core.
   *
   * @param {string} tableKey hex
   * @returns {Promise<object>} Hypercore instance (ready()-ed)
   */
  async _coreFor (tableKey) {
    const key = String(tableKey).toLowerCase()
    const existing = this._mirrors.get(key)
    if (existing) return existing.core
    const core = this.store.get({ name: CORE_NAME_PREFIX + key })
    await core.ready()
    return core
  }

  /**
   * Open a core for `tableKey`, replay its blocks into a freshly-created
   * PokerApp table, then wire forward mirroring so new appends are saved.
   *
   * Returns the PokerApp table descriptor.
   *
   * Throws if the table already exists in the PokerApp — call closeTable
   * first (or let the reaper handle it) before re-provisioning.
   *
   * @param {object} args
   * @param {string} args.tableKey
   * @param {string[]} args.writers
   * @param {object} [args.options]
   */
  async createPersistentTable (args) {
    if (this._stopped) throw new Error('HypercorePersistence: stopped')
    const key = String(args.tableKey).toLowerCase()
    const core = await this._coreFor(key)

    // Create the in-memory table first so we have somewhere to replay into.
    const desc = this.pokerApp.createTable({
      tableKey: key,
      writers: args.writers,
      options: args.options
    })

    // Replay existing blocks. We deliberately read all at once — tables are
    // bounded in size by maxEntriesPerTable and this only runs at startup.
    //
    // Validate shape BEFORE handing to replayEntries. SignedLog._replay is
    // documented as the trust-at-hydrate path — it does NOT re-validate
    // signatures, but it DOES dereference entry.writer / entry.seq / entry.ts
    // directly. A JSON-valid block missing those fields would throw a raw
    // TypeError inside the SignedLog and leave the in-memory table in a
    // half-replayed state. We catch that here instead: any bad block aborts
    // the whole replay, closes the half-created table, and surfaces the
    // index of the offender so the operator can repair.
    const length = core.length
    if (length > 0) {
      const entries = []
      for (let i = 0; i < length; i++) {
        const block = await core.get(i)
        let parsed
        try {
          parsed = JSON.parse(block.toString('utf8'))
        } catch (err) {
          this.pokerApp.closeTable(key)
          throw new Error('HypercorePersistence: corrupt block ' + i + ': ' + err.message)
        }
        const shapeError = _validateReplayEntryShape(parsed)
        if (shapeError) {
          this.pokerApp.closeTable(key)
          throw new Error('HypercorePersistence: bad-shape block ' + i + ': ' + shapeError)
        }
        entries.push(parsed)
      }
      this.pokerApp.replayEntries(key, entries)
      this.emit('hydrated', { tableKey: key, count: entries.length })
    }

    // Wire forward mirroring. Every successful in-memory append is queued
    // for core.append — failures are surfaced via 'mirror-error' but do
    // NOT roll back the in-memory append (the log is source of truth for
    // live play).
    const unsub = this.pokerApp.subscribe(key, (entry /*, index */) => {
      this._mirror(key, core, entry).catch((err) => {
        this.emit('mirror-error', { tableKey: key, error: err && err.message })
        this._log('mirror-error', { tableKey: key, error: err && err.message })
      })
    })

    this._mirrors.set(key, { core, unsub })
    return desc
  }

  /**
   * Stop mirroring for a single table. Does NOT close the core (it stays
   * available to the seeder for read), does NOT delete blocks, does NOT
   * close the PokerApp table (caller's responsibility).
   */
  detach (tableKey) {
    const key = String(tableKey).toLowerCase()
    const entry = this._mirrors.get(key)
    if (!entry) return false
    try { entry.unsub && entry.unsub() } catch {}
    this._mirrors.delete(key)
    return true
  }

  /**
   * Detach every table and mark the adapter stopped. Does NOT close the
   * underlying corestore (operator owns its lifecycle).
   */
  async stop () {
    this._stopped = true
    for (const { unsub } of this._mirrors.values()) {
      try { unsub && unsub() } catch {}
    }
    this._mirrors.clear()
  }

  /**
   * Public list of currently-mirrored tables — useful for ops dashboards.
   */
  listMirrors () {
    const out = []
    for (const [tableKey, { core }] of this._mirrors) {
      out.push({
        tableKey,
        coreKey: core.key ? core.key.toString('hex') : null,
        length: core.length
      })
    }
    return out
  }

  // ─── Internal ─────────────────────────────────────────────────────────────

  /**
   * Append `entry` to the table's core. Wrapped in a timeout so a stalled
   * Hypercore doesn't pile up unbounded promises behind it.
   */
  async _mirror (tableKey, core, entry) {
    if (this._stopped) return
    // Pad the serialized block up to a fixed size bucket so block sizes don't
    // leak the action type (see "Block padding" in the module header). The pad
    // is trailing whitespace OUTSIDE the JSON object, so JSON.parse on replay
    // recovers the exact entry and the signature is untouched.
    const blob = Buffer.from(_padBlock(JSON.stringify(entry), this._padBuckets), 'utf8')
    let timer
    const timeout = new Promise((_resolve, reject) => {
      timer = setTimeout(() => reject(new Error('append-timeout')), APPEND_TIMEOUT_MS)
    })
    try {
      await Promise.race([core.append(blob), timeout])
    } finally {
      clearTimeout(timer)
    }
  }
}

/**
 * Verify a parsed block looks like a SignedLog entry — enough that
 * `SignedLog._replay` won't crash on `entry.writer.toLowerCase()` or
 * `entry.seq` or `entry.ts`. We don't re-check the signature here (that
 * was checked at original ingest); we just ensure the fields exist with
 * the right primitive types.
 *
 * Returns null on OK, or a short reason string on failure.
 */
function _validateReplayEntryShape (e) {
  if (!e || typeof e !== 'object') return 'not-an-object'
  if (typeof e.writer !== 'string' || e.writer.length !== 64) return 'writer'
  if (typeof e.tableKey !== 'string' || e.tableKey.length !== 64) return 'tableKey'
  if (!Number.isInteger(e.seq) || e.seq < 0) return 'seq'
  if (typeof e.ts !== 'number' || !Number.isFinite(e.ts)) return 'ts'
  if (typeof e.signature !== 'string') return 'signature'
  return null
}

/**
 * Normalize the constructor `padding` option into an ascending bucket array,
 * or null when padding is disabled.
 *
 *   undefined / true  → DEFAULT_PAD_BUCKETS (padding on)
 *   false             → null (padding off, legacy raw JSON)
 *   number[]          → sorted positive integers (falls back to defaults if
 *                       the array has no usable entries)
 *
 * @param {undefined|boolean|number[]} padding
 * @returns {?number[]}
 */
function _resolvePadBuckets (padding) {
  if (padding === false) return null
  if (Array.isArray(padding)) {
    const buckets = padding
      .filter((n) => Number.isInteger(n) && n > 0)
      .sort((a, b) => a - b)
    return buckets.length ? buckets : DEFAULT_PAD_BUCKETS.slice()
  }
  return DEFAULT_PAD_BUCKETS.slice()
}

/**
 * Pad a JSON string up to the smallest bucket >= its UTF-8 byte length using
 * trailing ASCII spaces (1 byte each, ignored by JSON.parse). Blocks larger
 * than the top bucket round up to the next multiple of the top bucket. Returns
 * the input unchanged when padding is disabled (buckets === null) or the
 * string already sits exactly on a bucket boundary.
 *
 * @param {string} jsonStr
 * @param {?number[]} buckets  Ascending bucket ladder, or null to disable.
 * @returns {string}
 */
function _padBlock (jsonStr, buckets) {
  if (!buckets || buckets.length === 0) return jsonStr
  const len = Buffer.byteLength(jsonStr, 'utf8')
  let target = null
  for (const b of buckets) {
    if (b >= len) { target = b; break }
  }
  if (target === null) {
    const top = buckets[buckets.length - 1]
    target = Math.ceil(len / top) * top
  }
  if (target <= len) return jsonStr
  return jsonStr + ' '.repeat(target - len)
}

export {
  CORE_NAME_PREFIX,
  DEFAULT_PAD_BUCKETS,
  _validateReplayEntryShape as _validateReplayEntryShapeForTest,
  _resolvePadBuckets as _resolvePadBucketsForTest,
  _padBlock as _padBlockForTest
}