OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / scripts / simulate-auto-heal-bridge.js

605 lines · javascript ref:
#!/usr/bin/env node
/**
 * AutoHeal ↔ Anchor-Proof Bridge Simulation
 *
 * Exercises the real AutoHeal class against an in-memory simulated network
 * to surface emergent behaviors — convergence speed, malicious-operator
 * resistance, stampede prevention, and proof-fetch overhead.
 *
 * Mocks at the boundary: each simulated relay has a real AutoHeal instance
 * plus a fake `federation.snapshot()` that returns a global world view, a
 * fake `seedApp()` that updates the world, and a fake `fetchProof()` that
 * returns `{ ok: true, proof: { anchored } }` only when the target relay is
 * actually hosting the drive (honest) — otherwise `{ ok: false }`
 * (cryptographically would-be invalid).
 *
 * Run:
 *   node scripts/simulate-auto-heal-bridge.js [scenario]
 *
 * Scenarios:
 *   cold-start          — empty net, one app published, watch convergence
 *   sybil-attack        — N attackers claim same operator/region
 *   liar-attack         — peers claim anchored without valid proofs
 *   churn               — 10%/tick of relays go offline
 *   stampede            — 100 relays simultaneously below threshold
 *   partition-heal      — split-brain heals when partition resolves
 *   all                 — run all scenarios
 */

import { AutoHeal } from '../packages/core/core/auto-heal.js'

// ─── Deterministic RNG for reproducibility ─────────────────────────

function mulberry32 (seed) {
  let t = seed >>> 0
  return () => {
    t = (t + 0x6d2b79f5) >>> 0
    let r = t
    r = Math.imul(r ^ (r >>> 15), r | 1)
    r ^= r + Math.imul(r ^ (r >>> 7), r | 61)
    return ((r ^ (r >>> 14)) >>> 0) / 4294967296
  }
}

// ─── Simulated World ────────────────────────────────────────────────

const REGIONS = ['NA', 'EU', 'AS', 'OC', 'SA', 'AF']
const OPERATORS = ['op-amazon', 'op-coreweave', 'op-hetzner', 'op-vultr', 'op-fly', 'op-railway', 'op-render', 'op-digitalocean']

class World {
  constructor (rng) {
    this.rng = rng
    this.relays = new Map() // pubkey → { region, operator, url, hosting:Set, online:bool, lying:bool }
    this.partitions = null // null or [Set<pubkey>, Set<pubkey>]
  }

  addRelay (pubkey, opts = {}) {
    this.relays.set(pubkey, {
      pubkey,
      region: opts.region || REGIONS[Math.floor(this.rng() * REGIONS.length)],
      operator: opts.operator || OPERATORS[Math.floor(this.rng() * OPERATORS.length)],
      url: 'https://' + pubkey + '.example',
      hosting: new Set(opts.hosting || []),
      online: opts.online !== false,
      lying: !!opts.lying
    })
  }

  // Snapshot for relay `viewer` — applies partition view if any
  snapshotFor (viewer) {
    const out = []
    for (const [pk, r] of this.relays) {
      if (pk === viewer) continue
      if (!r.online) continue
      // Apply partition: a relay only sees others on its side
      if (this.partitions) {
        const [a, b] = this.partitions
        const myside = a.has(viewer) ? a : (b.has(viewer) ? b : null)
        if (myside && !myside.has(pk)) continue
      }
      const apps = []
      for (const appKey of r.hosting) {
        apps.push({ appKey, durability: 1, anchored: !r.lying || true /* always claim anchored */ })
      }
      // Liars also claim anchored for things they DON'T host
      if (r.lying) {
        // For every appKey hosted anywhere in the network, pretend we have it
        const allApps = new Set()
        for (const r2 of this.relays.values()) for (const a of r2.hosting) allApps.add(a)
        for (const a of allApps) {
          if (!r.hosting.has(a)) apps.push({ appKey: a, durability: 1, anchored: true })
        }
      }
      out.push({ pubkey: pk, region: r.region, url: r.url, operator: r.operator, apps })
    }
    return { peerCatalogs: out }
  }

  // Mock proof fetcher: returns ok only if the target relay is actually hosting the drive
  fetcher () {
    return async (url, appKey, opts) => {
      const pk = url.replace('https://', '').replace('.example', '')
      const r = this.relays.get(pk)
      if (!r) return { ok: false, reason: 'no-such-peer' }
      if (!r.online) return { ok: false, reason: 'fetch-error' }
      if (!r.hosting.has(appKey)) {
        // Liars hit this branch — they claim anchored but cannot produce a valid proof
        return { ok: false, reason: 'bad-signature-cryptographic' }
      }
      return {
        ok: true,
        proof: {
          appKey,
          anchored: true,
          version: 1,
          attestedAt: Date.now(),
          relayPubkey: pk,
          signature: 'simulated-valid-sig'
        },
        fetchedAt: Date.now()
      }
    }
  }
}

// ─── Build a real AutoHeal-driven relay backed by the world ────────

function makeSimRelay (world, pubkey, opts = {}) {
  const meta = world.relays.get(pubkey)
  if (!meta) throw new Error('relay not in world: ' + pubkey)

  const seededApps = []
  const node = {
    config: { regions: [meta.region], operator: meta.operator, autoHeal: { enabled: true } },
    swarm: { keyPair: { publicKey: pubkey } },
    appRegistry: {
      catalog: () => Array.from(meta.hosting).map(appKey => ({
        appKey,
        durability: 1,
        anchored: true,
        region: meta.region
      })),
      has: (appKey) => meta.hosting.has(appKey)
    },
    federation: {
      snapshot: () => world.snapshotFor(pubkey)
    },
    seedApp: async (appKey, opts) => {
      meta.hosting.add(appKey)
      seededApps.push({ appKey, opts })
    },
    _resolveAcceptMode: () => 'open',
    _decideAcceptance: () => 'accept',
    _seededApps: seededApps
  }

  const heal = new AutoHeal(node, {
    tickMs: 60_000,
    verifyProofs: true,
    fetchProof: world.fetcher(),
    proofFreshnessMs: 60 * 60_000,
    maxRecruitsPerTick: opts.maxRecruitsPerTick ?? 2,
    convergence: opts.convergence ?? 1.0, // disable jitter for determinism unless overridden
    random: opts.random ?? world.rng,
    storage: { maxBytes: Number.MAX_SAFE_INTEGER, currentBytes: 0 },
    ...opts.healOpts
  })
  heal._running = true

  return { pubkey, meta, node, heal, seededApps }
}

// ─── Helpers for scenario reporting ────────────────────────────────

function countReplicas (world, appKey) {
  let n = 0
  for (const r of world.relays.values()) {
    if (r.online && r.hosting.has(appKey)) n++
  }
  return n
}

function regionsCovering (world, appKey) {
  const s = new Set()
  for (const r of world.relays.values()) {
    if (r.online && r.hosting.has(appKey)) s.add(r.region)
  }
  return s
}

function operatorsCovering (world, appKey) {
  const s = new Set()
  for (const r of world.relays.values()) {
    if (r.online && r.hosting.has(appKey)) s.add(r.operator)
  }
  return s
}

function fmtSnap (world, appKey) {
  const replicas = countReplicas(world, appKey)
  const regions = regionsCovering(world, appKey)
  const ops = operatorsCovering(world, appKey)
  return `replicas=${replicas} regions=${[...regions].sort().join(',') || '-'} ops=${ops.size}`
}

// ─── Tick driver ───────────────────────────────────────────────────

async function tickAll (relays) {
  for (const r of relays) {
    if (!r.meta.online) continue
    await r.heal._tick()
  }
}

// ─── Scenarios ─────────────────────────────────────────────────────

async function scenarioColdStart () {
  console.log('\n══ Scenario: cold-start ══')
  console.log('20 relays across 6 regions / 8 operators. One app published. Watch convergence to 5/3/3.')

  const world = new World(mulberry32(42))
  for (let i = 0; i < 20; i++) world.addRelay('r' + i)

  // r0 publishes the drive
  world.relays.get('r0').hosting.add('app-cold')

  const relays = []
  for (let i = 0; i < 20; i++) {
    relays.push(makeSimRelay(world, 'r' + i, {
      healOpts: {
        thresholds: { minReplicas: 5, minRegions: 3, minOperators: 3 }
      }
    }))
  }

  let metAtTick = -1
  for (let tick = 1; tick <= 30; tick++) {
    await tickAll(relays)
    const reps = countReplicas(world, 'app-cold')
    const regs = regionsCovering(world, 'app-cold')
    const ops = operatorsCovering(world, 'app-cold')
    if (reps >= 5 && regs.size >= 3 && ops.size >= 3 && metAtTick < 0) metAtTick = tick
    if (tick <= 5 || tick % 5 === 0 || metAtTick === tick) {
      console.log(`  tick ${String(tick).padStart(2)}: ${fmtSnap(world, 'app-cold')}${metAtTick === tick ? ' ◀ THRESHOLD MET' : ''}`)
    }
    if (metAtTick > 0 && tick >= metAtTick + 2) break
  }
  console.log(`→ converged at tick ${metAtTick} ${metAtTick > 0 ? '✓' : '✗ NEVER converged'}`)

  // proof-fetch cost discovery
  let totalProofs = 0
  for (const r of relays) totalProofs += r.heal._proofCache.size
  console.log(`→ total cached proofs across network: ${totalProofs} (avg ${(totalProofs / relays.length).toFixed(1)}/relay)`)

  return { converged: metAtTick > 0, ticks: metAtTick, totalProofs }
}

async function scenarioSybilAttack () {
  console.log('\n══ Scenario: sybil-attack ══')
  console.log('5 honest diverse relays + 50 sybils all on same operator/region. Should diversity defeat them?')

  const world = new World(mulberry32(7))
  // 5 diverse honest relays
  const honest = [
    { pk: 'h0', region: 'NA', operator: 'op-real-a' },
    { pk: 'h1', region: 'EU', operator: 'op-real-b' },
    { pk: 'h2', region: 'AS', operator: 'op-real-c' },
    { pk: 'h3', region: 'SA', operator: 'op-real-d' },
    { pk: 'h4', region: 'OC', operator: 'op-real-e' }
  ]
  for (const h of honest) world.addRelay(h.pk, { region: h.region, operator: h.operator })

  // 50 sybils — same operator, same region
  for (let i = 0; i < 50; i++) {
    world.addRelay('s' + i, { region: 'NA', operator: 'op-sybil' })
  }

  // Honest h0 hosts the drive
  world.relays.get('h0').hosting.add('app-sybil')

  const relays = []
  for (const h of honest) {
    relays.push(makeSimRelay(world, h.pk, {
      healOpts: { thresholds: { minReplicas: 4, minRegions: 4, minOperators: 4 } }
    }))
  }
  for (let i = 0; i < 50; i++) {
    relays.push(makeSimRelay(world, 's' + i, {
      healOpts: { thresholds: { minReplicas: 4, minRegions: 4, minOperators: 4 } }
    }))
  }

  let capSkipsTotal = 0
  for (const r of relays) {
    r.heal.on('recruit-skipped', e => {
      if (e.reason === 'operator-fairshare-cap') capSkipsTotal++
    })
  }

  for (let tick = 1; tick <= 15; tick++) await tickAll(relays)

  let sybilHosts = 0
  let honestHosts = 0
  for (const r of world.relays.values()) {
    if (r.hosting.has('app-sybil')) {
      if (r.operator === 'op-sybil') sybilHosts++
      else honestHosts++
    }
  }
  console.log(`  honest replicas: ${honestHosts} | sybil replicas: ${sybilHosts} | cap-skips: ${capSkipsTotal}`)
  console.log(`  total: ${honestHosts + sybilHosts}, regions: ${regionsCovering(world, 'app-sybil').size}, ops: ${operatorsCovering(world, 'app-sybil').size}`)
  console.log(`→ sybil over-recruitment: ${sybilHosts > 5 ? '✗ over-replicated by sybils' : '✓ diversity bounds sybil cluster'}`)

  return { honestHosts, sybilHosts }
}

async function scenarioLiarAttack () {
  console.log('\n══ Scenario: liar-attack ══')
  console.log('10 honest + 10 liars (claim anchored, fail proof verification). Should liars NOT count.')

  const world = new World(mulberry32(13))
  for (let i = 0; i < 10; i++) world.addRelay('h' + i)
  for (let i = 0; i < 10; i++) {
    world.addRelay('l' + i, { lying: true })
  }
  world.relays.get('h0').hosting.add('app-liar')

  const relays = []
  for (let i = 0; i < 10; i++) {
    relays.push(makeSimRelay(world, 'h' + i, {
      healOpts: { thresholds: { minReplicas: 5, minRegions: 3, minOperators: 3 } }
    }))
  }
  for (let i = 0; i < 10; i++) {
    relays.push(makeSimRelay(world, 'l' + i, {
      healOpts: { thresholds: { minReplicas: 5, minRegions: 3, minOperators: 3 } }
    }))
  }

  // Track proof-failed events
  const proofFailures = new Map() // pubkey -> count
  for (const r of relays) {
    r.heal.on('proof-failed', e => {
      proofFailures.set(e.peerPubkey, (proofFailures.get(e.peerPubkey) || 0) + 1)
    })
  }

  for (let tick = 1; tick <= 20; tick++) {
    await tickAll(relays)
  }

  // Liars should never accumulate the drive — they don't actually seed it
  let liarsHosting = 0
  let honestHosting = 0
  for (const [pk, r] of world.relays) {
    if (r.hosting.has('app-liar')) {
      if (pk.startsWith('l')) liarsHosting++
      else honestHosting++
    }
  }
  let liarsFailedProof = 0
  for (const [pk, n] of proofFailures) {
    if (pk.startsWith('l') && n > 0) liarsFailedProof++
  }
  console.log(`  honest hosting: ${honestHosting} | liars hosting (don't actually serve): ${liarsHosting}`)
  console.log(`  liars caught failing proof: ${liarsFailedProof}/10`)
  console.log(`→ ${liarsFailedProof === 10 ? '✓ all liars detected by proof failure' : `✗ only ${liarsFailedProof}/10 liars detected`}`)

  return { honestHosting, liarsHosting, liarsCaught: liarsFailedProof }
}

async function scenarioChurn () {
  console.log('\n══ Scenario: churn ══')
  console.log('Compare 5%, 10%, 20% per-tick churn. Does AutoHeal recover?')

  const results = {}
  for (const churnRate of [0.02, 0.05, 0.10, 0.20]) {
    const world = new World(mulberry32(99))
    for (let i = 0; i < 30; i++) world.addRelay('r' + i)
    world.relays.get('r0').hosting.add('app-churn')

    const relays = []
    for (let i = 0; i < 30; i++) {
      relays.push(makeSimRelay(world, 'r' + i, {
        healOpts: { thresholds: { minReplicas: 5, minRegions: 3, minOperators: 3 } }
      }))
    }

    let ticksAtThreshold = 0
    let ticksTotal = 0
    let minLive = Infinity
    let totalRecruits = 0
    for (let tick = 1; tick <= 50; tick++) {
      for (const r of world.relays.values()) {
        if (world.rng() < churnRate) r.online = !r.online
      }
      world.relays.get('r0').online = true

      await tickAll(relays)
      const after = relays.reduce((s, r) => s + r.seededApps.length, 0)
      totalRecruits = after

      const reps = countReplicas(world, 'app-churn')
      const regs = regionsCovering(world, 'app-churn')
      const ops = operatorsCovering(world, 'app-churn')
      minLive = Math.min(minLive, reps)
      ticksTotal++
      if (reps >= 5 && regs.size >= 3 && ops.size >= 3) ticksAtThreshold++
    }
    const heldPct = Math.round(100 * ticksAtThreshold / ticksTotal)
    console.log(`  churn=${(churnRate * 100).toFixed(0)}%: held ${heldPct}% of ticks, min live=${minLive}, total recruits=${totalRecruits}`)
    results['churn' + (churnRate * 100).toFixed(0)] = { heldPct, minLive, totalRecruits }
  }
  console.log('→ Watch the held% and minLive trends — recovery rate vs. churn rate')
  return results
}

async function scenarioStampede () {
  console.log('\n══ Scenario: stampede ══')
  console.log('100 relays simultaneously below threshold, jitter ON. Recruitment should NOT all fire same tick.')

  const world = new World(mulberry32(55))
  for (let i = 0; i < 100; i++) world.addRelay('r' + i)
  world.relays.get('r0').hosting.add('app-stampede')

  const relays = []
  for (let i = 0; i < 100; i++) {
    relays.push(makeSimRelay(world, 'r' + i, {
      // Use real jitter — convergence ≈ 2K/N where K = minReplicas, N = network size
      convergence: 0.05, // very low — should defer most recruits
      healOpts: { thresholds: { minReplicas: 5, minRegions: 3, minOperators: 3 } }
    }))
  }

  const recruitsPerTick = []
  let totalRecruits = 0
  let firstThresholdTick = -1
  for (let tick = 1; tick <= 30; tick++) {
    const before = relays.reduce((s, r) => s + r.seededApps.length, 0)
    await tickAll(relays)
    const after = relays.reduce((s, r) => s + r.seededApps.length, 0)
    const delta = after - before
    recruitsPerTick.push(delta)
    totalRecruits = after
    const reps = countReplicas(world, 'app-stampede')
    if (reps >= 5 && firstThresholdTick < 0) firstThresholdTick = tick
  }

  const max = Math.max(...recruitsPerTick)
  const total = totalRecruits
  console.log(`  recruits/tick (first 10): ${recruitsPerTick.slice(0, 10).join(' ')}`)
  console.log(`  peak recruits in single tick: ${max} | total recruits over 30 ticks: ${total}`)
  console.log(`  threshold reached at tick ${firstThresholdTick}`)
  console.log(`→ stampede ${max < 30 ? '✓ avoided' : '✗ peak ≥30/tick'}, over-recruit ${total > 20 ? '✗ wasteful (' + total + ' for need-5)' : '✓ controlled'}`)
  return { peak: max, total, firstThresholdTick }
}

async function scenarioPartitionHeal () {
  console.log('\n══ Scenario: partition-heal ══')
  console.log('Side A = 10 relays in NA only; side B = 10 relays in EU/AS/SA. r0 (NA) publishes.')
  console.log('After partition heals, side A has 5 NA replicas (1 region). Threshold needs 3 regions.')
  console.log('Question: does side B step up post-heal to add region diversity?')

  const world = new World(mulberry32(111))
  // Side A: all NA
  for (let i = 0; i < 10; i++) world.addRelay('r' + i, { region: 'NA', operator: 'op-' + (i % 3) })
  // Side B: spread across EU/AS/SA
  const sideBRegions = ['EU', 'EU', 'EU', 'AS', 'AS', 'AS', 'SA', 'SA', 'OC', 'AF']
  for (let i = 10; i < 20; i++) {
    world.addRelay('r' + i, { region: sideBRegions[i - 10], operator: 'op-b' + (i % 4) })
  }
  world.relays.get('r0').hosting.add('app-partition')

  const sideA = new Set()
  const sideB = new Set()
  for (let i = 0; i < 10; i++) sideA.add('r' + i)
  for (let i = 10; i < 20; i++) sideB.add('r' + i)
  world.partitions = [sideA, sideB]

  const relays = []
  for (let i = 0; i < 20; i++) {
    relays.push(makeSimRelay(world, 'r' + i, {
      healOpts: { thresholds: { minReplicas: 5, minRegions: 3, minOperators: 3 } }
    }))
  }

  for (let tick = 1; tick <= 15; tick++) await tickAll(relays)
  const sideAReplicas = [...sideA].filter(pk => world.relays.get(pk).hosting.has('app-partition')).length
  const sideBReplicas = [...sideB].filter(pk => world.relays.get(pk).hosting.has('app-partition')).length
  console.log(`  PARTITIONED — side A: ${sideAReplicas} (regions: ${[...regionsCovering(world, 'app-partition')].join(',')}), side B: ${sideBReplicas}`)

  world.partitions = null
  let healedTick = -1
  for (let tick = 16; tick <= 50; tick++) {
    await tickAll(relays)
    const sideBNow = [...sideB].filter(pk => world.relays.get(pk).hosting.has('app-partition')).length
    if (sideBNow > 0 && healedTick < 0) healedTick = tick
  }
  const sideBFinal = [...sideB].filter(pk => world.relays.get(pk).hosting.has('app-partition')).length
  const finalRegions = regionsCovering(world, 'app-partition')
  console.log(`  HEALED — side B first recruit at tick ${healedTick}, final side B: ${sideBFinal}, total regions: ${finalRegions.size}`)
  console.log(`→ ${healedTick > 0 ? '✓ region-diversity gap forced cross-side recruitment' : '✗ side B never recruits — region count satisfied locally?'}`)
  return { healedTick, sideBFinal, finalRegions: [...finalRegions] }
}

async function scenarioProofScaling () {
  console.log('\n══ Scenario: proof-scaling ══')
  console.log('Measure proof-fetch cost as N grows. Expecting O(K·R) per tick where K=anchored peers, R=relays.')

  const ns = [10, 25, 50, 100]
  const data = []
  for (const N of ns) {
    const world = new World(mulberry32(N * 7))
    for (let i = 0; i < N; i++) world.addRelay('r' + i)
    // K=5 hosting peers spread across the network
    for (let i = 0; i < 5; i++) world.relays.get('r' + i).hosting.add('app-scale')

    let fetchCount = 0
    const baseFetch = world.fetcher()
    const wrappedFetch = async (url, appKey, opts) => {
      fetchCount++
      return baseFetch(url, appKey, opts)
    }

    const relays = []
    for (let i = 0; i < N; i++) {
      const meta = world.relays.get('r' + i)
      const node = {
        config: { regions: [meta.region], autoHeal: { enabled: true } },
        swarm: { keyPair: { publicKey: 'r' + i } },
        appRegistry: {
          catalog: () => Array.from(meta.hosting).map(appKey => ({ appKey, durability: 1, anchored: true, region: meta.region })),
          has: (appKey) => meta.hosting.has(appKey)
        },
        federation: { snapshot: () => world.snapshotFor('r' + i) },
        seedApp: async (appKey) => meta.hosting.add(appKey),
        _resolveAcceptMode: () => 'open',
        _decideAcceptance: () => 'accept'
      }
      const heal = new AutoHeal(node, {
        tickMs: 60_000,
        verifyProofs: true,
        fetchProof: wrappedFetch,
        proofFreshnessMs: 60 * 60_000,
        thresholds: { minReplicas: 5, minRegions: 3, minOperators: 3 },
        convergence: 1.0
      })
      heal._running = true
      relays.push({ heal, meta })
    }

    fetchCount = 0
    await tickAll(relays.map(r => ({ ...r, meta: r.meta })))
    const tick1Fetches = fetchCount
    fetchCount = 0
    await tickAll(relays.map(r => ({ ...r, meta: r.meta })))
    const tick2Fetches = fetchCount

    data.push({ N, K: 5, tick1: tick1Fetches, tick2: tick2Fetches })
    console.log(`  N=${String(N).padStart(3)}: tick1=${tick1Fetches} fetches, tick2=${tick2Fetches} (cached) — ratio tick1/(K·N)=${(tick1Fetches / (5 * N)).toFixed(2)}`)
  }
  console.log('→ Tick-1 fetches scale ≈ K·(N−1) (each relay fetches each anchored peer\'s proof once)')
  return { data }
}

// ─── Runner ─────────────────────────────────────────────────────────

const SCENARIOS = {
  'cold-start': scenarioColdStart,
  sybil: scenarioSybilAttack,
  liar: scenarioLiarAttack,
  churn: scenarioChurn,
  stampede: scenarioStampede,
  partition: scenarioPartitionHeal,
  scaling: scenarioProofScaling
}

async function main () {
  const which = process.argv[2] || 'all'
  console.log('AutoHeal Bridge Simulation')
  console.log('===========================')

  const results = {}
  if (which === 'all') {
    for (const [name, fn] of Object.entries(SCENARIOS)) {
      results[name] = await fn()
    }
  } else if (SCENARIOS[which]) {
    results[which] = await SCENARIOS[which]()
  } else {
    console.error('Unknown scenario:', which)
    console.error('Available:', Object.keys(SCENARIOS).join(', '), 'or "all"')
    process.exit(1)
  }

  console.log('\n══ SUMMARY ══')
  for (const [name, r] of Object.entries(results)) {
    console.log(`  ${name}:`, JSON.stringify(r))
  }
}

main().catch(err => {
  console.error(err)
  process.exit(1)
})