OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / scripts / simulate-blind-atomic-custody.js

559 lines · javascript ref:
#!/usr/bin/env node

import { selectQuorum, describeQuorum } from '../packages/core/core/quorum-selector.js'

const DEFAULT_ITERATIONS = 5000
const DEFAULT_SEED = 'hiverelay-blind-atomic-v1'

const SCENARIOS = [
  {
    name: 'mirror-3-score',
    description: '3 full encrypted mirrors, score-biased selection, no expiry witnesses',
    custodyCount: 3,
    receiptQuorum: 2,
    readThreshold: 1,
    selection: 'score',
    fullMirror: true,
    witnessCount: 0
  },
  {
    name: 'mirror-5-diverse',
    description: '5 full encrypted mirrors, operator/region diverse selection, no expiry witnesses',
    custodyCount: 5,
    receiptQuorum: 3,
    readThreshold: 1,
    selection: 'diverse',
    fullMirror: true,
    witnessCount: 0
  },
  {
    name: 'shards-10of16-random',
    description: '16 encrypted shards, 10 needed to recover, random custody relays',
    custodyCount: 16,
    receiptQuorum: 13,
    readThreshold: 10,
    selection: 'random',
    fullMirror: false,
    witnessCount: 0
  },
  {
    name: 'shards-10of16-diverse',
    description: '16 encrypted shards, 10 needed to recover, diverse custody relays',
    custodyCount: 16,
    receiptQuorum: 13,
    readThreshold: 10,
    selection: 'diverse',
    fullMirror: false,
    witnessCount: 0
  },
  {
    name: 'shards-10of16-witness5',
    description: '16 encrypted shards plus 5 independent expiry witnesses',
    custodyCount: 16,
    receiptQuorum: 13,
    readThreshold: 10,
    selection: 'diverse',
    fullMirror: false,
    witnessCount: 5,
    witnessRounds: 1,
    witnessSelection: 'diverse'
  },
  {
    name: 'shards-10of16-witness3x3',
    description: '16 encrypted shards plus 3 rotating witness rounds of 3 witnesses',
    custodyCount: 16,
    receiptQuorum: 13,
    readThreshold: 10,
    selection: 'diverse',
    fullMirror: false,
    witnessCount: 3,
    witnessRounds: 3,
    witnessSelection: 'diverse'
  },
  {
    name: 'shards-8of24-witness7',
    description: '24 encrypted shards, 8 needed to recover, 7 independent expiry witnesses',
    custodyCount: 24,
    receiptQuorum: 18,
    readThreshold: 8,
    selection: 'diverse',
    fullMirror: false,
    witnessCount: 7,
    witnessRounds: 1,
    witnessSelection: 'diverse'
  }
]

function simulateScenario (scenario, config) {
  const totals = {
    committed: 0,
    available: 0,
    adversaryReconstructsCiphertext: 0,
    activeServingViolation: 0,
    undetectedActiveServing: 0,
    custodyRegions: 0,
    custodyOperators: 0,
    witnessRegions: 0,
    witnessOperators: 0,
    honestWitnesses: 0,
    witnessProbes: 0
  }

  for (let i = 0; i < config.iterations; i++) {
    const rng = new Rng(hash32(`${config.seed}:${scenario.name}:${i}`))
    const network = makeNetwork(rng, config)
    const custodians = pickRelays(network, scenario.custodyCount, scenario.selection, rng)
    const custodySummary = describeQuorum(custodians)
    totals.custodyRegions += custodySummary.regions.length
    totals.custodyOperators += custodySummary.operators.length

    const receipts = custodians.filter(relay => relayAnchors(relay, rng))
    const committed = receipts.length >= scenario.receiptQuorum
    if (!committed) continue

    totals.committed++
    if (hasEnoughServingRelays(receipts, scenario.readThreshold, rng, config)) totals.available++
    if (adversaryCanReconstruct(receipts, scenario)) totals.adversaryReconstructsCiphertext++

    const violators = receipts.filter(relay => continuesServingAfterExpiry(relay, rng, config))
    if (violators.length === 0) continue
    totals.activeServingViolation++

    if (!witnessViolationDetected(network, custodians, violators, scenario, rng, config, totals)) {
      totals.undetectedActiveServing++
    }
  }

  const committed = totals.committed || 1
  const witnessRounds = scenario.witnessRounds || (scenario.witnessCount ? 1 : 0)
  const maxWitnessProbes = (scenario.witnessCount || 0) * witnessRounds
  return {
    name: scenario.name,
    description: scenario.description,
    custodyCount: scenario.custodyCount,
    receiptQuorum: scenario.receiptQuorum,
    readThreshold: scenario.readThreshold,
    witnessCount: scenario.witnessCount,
    witnessRounds,
    maxWitnessProbes,
    costUnits: scenario.custodyCount + maxWitnessProbes * 0.35,
    commitRate: ratio(totals.committed, config.iterations),
    availabilityAfterSourceStop: ratio(totals.available, committed),
    adversaryReconstructsCiphertext: ratio(totals.adversaryReconstructsCiphertext, committed),
    activeServingViolation: ratio(totals.activeServingViolation, committed),
    undetectedActiveServing: ratio(totals.undetectedActiveServing, committed),
    avgCustodyRegions: round(totals.custodyRegions / config.iterations),
    avgCustodyOperators: round(totals.custodyOperators / config.iterations),
    avgWitnessRegions: scenario.witnessCount ? round(totals.witnessRegions / committed) : 0,
    avgWitnessOperators: scenario.witnessCount ? round(totals.witnessOperators / committed) : 0,
    avgHonestWitnesses: scenario.witnessCount ? round(totals.honestWitnesses / committed) : 0,
    avgWitnessProbes: scenario.witnessCount ? round(totals.witnessProbes / committed) : 0,
    score: 0
  }
}

function makeNetwork (rng, config) {
  const maliciousOperators = new Set()
  for (let i = 0; i < config.operatorCount; i++) {
    if (rng.next() < config.maliciousOperatorRate) maliciousOperators.add(`op-${i}`)
  }

  const relays = []
  for (let i = 0; i < config.relayCount; i++) {
    const operatorIndex = weightedOperatorIndex(i, rng, config.operatorCount)
    const operator = `op-${operatorIndex}`
    const region = `region-${(i + operatorIndex + rng.int(config.regionCount)) % config.regionCount}`
    const reliability = clamp(0.82 + rng.next() * 0.17 + (operatorIndex % 3 === 0 ? 0.03 : 0), 0.75, 0.995)
    const latencyMs = Math.round(25 + rng.next() * 275 + (operatorIndex % 4) * 15)
    const score = clamp((reliability * 0.82) + ((300 - latencyMs) / 300 * 0.18), 0, 1)

    relays.push({
      pubkey: hexFromInt(i, operatorIndex),
      operator,
      region,
      reliability,
      latencyMs,
      score,
      malicious: maliciousOperators.has(operator),
      features: ['blind-custody', 'custody-expiry', 'non-serving-proof']
    })
  }
  return relays
}

function weightedOperatorIndex (relayIndex, rng, operatorCount) {
  if (relayIndex < operatorCount) return relayIndex
  if (rng.next() < 0.48) return rng.int(Math.max(3, Math.floor(operatorCount / 4)))
  return rng.int(operatorCount)
}

function pickRelays (network, count, strategy, rng, exclude = new Set()) {
  if (count <= 0) return []
  const pool = network.filter(relay => !exclude.has(relay.pubkey))
  if (strategy === 'random') return rng.shuffle(pool).slice(0, count)
  if (strategy === 'score') return [...pool].sort((a, b) => b.score - a.score || a.latencyMs - b.latencyMs).slice(0, count)
  return selectQuorum(pool, {
    strategy: 'diverse',
    size: count,
    minRegions: Math.min(4, count),
    requireFeatures: ['blind-custody']
  })
}

function relayAnchors (relay, rng) {
  const adversarialBoost = relay.malicious ? 0.04 : 0
  return rng.next() < clamp(relay.reliability + adversarialBoost, 0, 0.998)
}

function hasEnoughServingRelays (receipts, readThreshold, rng, config) {
  let serving = 0
  for (const relay of receipts) {
    const online = rng.next() < relay.reliability
    const censoring = relay.malicious && rng.next() < config.maliciousCensorRate
    if (online && !censoring) serving++
  }
  return serving >= readThreshold
}

function adversaryCanReconstruct (receipts, scenario) {
  const maliciousReceipts = receipts.filter(relay => relay.malicious).length
  if (scenario.fullMirror) return maliciousReceipts >= 1
  return maliciousReceipts >= scenario.readThreshold
}

function continuesServingAfterExpiry (relay, rng, config) {
  const p = relay.malicious ? config.maliciousContinuesServingRate : config.honestBugContinuesServingRate
  return rng.next() < p
}

function violationDetected (violators, honestWitnesses, rng, config) {
  if (violators.length === 0) return true
  for (let i = 0; i < violators.length; i++) {
    for (let j = 0; j < honestWitnesses.length; j++) {
      if (rng.next() < config.witnessDetectRate) return true
    }
  }
  return false
}

function witnessViolationDetected (network, custodians, violators, scenario, rng, config, totals) {
  if (violators.length === 0) return true
  if (!scenario.witnessCount) return false

  const rounds = scenario.witnessRounds || 1
  const exclude = new Set(custodians.map(r => r.pubkey))

  for (let round = 0; round < rounds; round++) {
    const witnesses = pickRelays(network, scenario.witnessCount, scenario.witnessSelection || 'diverse', rng, exclude)
    for (const witness of witnesses) exclude.add(witness.pubkey)

    const witnessSummary = describeQuorum(witnesses)
    totals.witnessRegions += witnessSummary.regions.length
    totals.witnessOperators += witnessSummary.operators.length
    totals.witnessProbes += witnesses.length

    const honestWitnesses = witnesses.filter(w => !w.malicious)
    totals.honestWitnesses += honestWitnesses.length
    if (violationDetected(violators, honestWitnesses, rng, config)) return true
  }

  return false
}

function summarizeBreakthrough (results) {
  const sharded = results.find(r => r.name === 'shards-10of16-diverse')
  const witnessed = results.find(r => r.name === 'shards-10of16-witness5')
  if (!sharded || !witnessed) return null
  return {
    primitive: 'witness-tombstone-quorum',
    observation: 'independent non-custody witnesses reduce undetected post-expiry serving without adding storage replicas or giving more relays content',
    undetectedServingReduction: round(sharded.undetectedActiveServing - witnessed.undetectedActiveServing),
    availabilityDelta: round(witnessed.availabilityAfterSourceStop - sharded.availabilityAfterSourceStop),
    ciphertextReconstructionDelta: round(witnessed.adversaryReconstructsCiphertext - sharded.adversaryReconstructsCiphertext)
  }
}

function printReport (config, results) {
  console.log('HiveRelay blind atomic custody simulation')
  console.log(`iterations=${config.iterations} seed=${config.seed}`)
  console.log(`network=${config.relayCount} relays, ${config.operatorCount} operators, ${config.regionCount} regions`)
  console.log('')
  console.log([
    pad('scenario', 26),
    pad('commit', 9),
    pad('avail', 9),
    pad('adv-recon', 11),
    pad('active-leak', 12),
    pad('undetected', 12),
    pad('ops', 6),
    pad('watchers', 8)
  ].join(' '))
  console.log('-'.repeat(99))
  for (const r of results) {
    r.score = scoreDesign(r)
    console.log([
      pad(r.name, 26),
      pad(percent(r.commitRate), 9),
      pad(percent(r.availabilityAfterSourceStop), 9),
      pad(percent(r.adversaryReconstructsCiphertext), 11),
      pad(percent(r.activeServingViolation), 12),
      pad(percent(r.undetectedActiveServing), 12),
      pad(String(r.avgCustodyOperators), 6),
      pad(r.maxWitnessProbes ? `${r.avgHonestWitnesses}/${r.maxWitnessProbes}` : '0', 8)
    ].join(' '))
  }
  console.log('')
  const breakthrough = summarizeBreakthrough(results)
  console.log('Breakthrough candidate: Witness Tombstone Quorum')
  console.log(`- Undetected active-serving risk improvement: ${percent(breakthrough.undetectedServingReduction)}`)
  console.log(`- Availability delta versus diverse shards: ${percent(breakthrough.availabilityDelta)}`)
  console.log(`- Ciphertext reconstruction delta versus diverse shards: ${percent(breakthrough.ciphertextReconstructionDelta)}`)
  console.log('- Interpretation: add independent expiry witnesses before adding more custody replicas.')
}

function generateSweepScenarios () {
  const scenarios = []
  for (const custodyCount of [12, 16, 20, 24, 28, 32]) {
    const thresholdCandidates = new Set([
      Math.ceil(custodyCount * 0.45),
      Math.ceil(custodyCount * 0.55),
      Math.ceil(custodyCount * 0.625),
      Math.ceil(custodyCount * 0.7)
    ])

    for (const readThreshold of thresholdCandidates) {
      if (readThreshold >= custodyCount) continue
      for (const witnessCount of [0, 3, 5, 7]) {
        for (const witnessRounds of witnessCount ? [1, 2, 3] : [0]) {
          const receiptQuorum = Math.max(readThreshold, Math.ceil(custodyCount * 0.78))
          scenarios.push({
            name: `shards-${readThreshold}of${custodyCount}-w${witnessCount}x${witnessRounds}`,
            description: `${custodyCount} shards, ${readThreshold} needed, ${witnessCount} witnesses x ${witnessRounds} rounds`,
            custodyCount,
            receiptQuorum,
            readThreshold,
            selection: 'diverse',
            fullMirror: false,
            witnessCount,
            witnessRounds,
            witnessSelection: 'diverse'
          })
        }
      }
    }
  }
  return scenarios
}

function runSweep (config, opts = {}) {
  const limit = toPositiveInt(opts.limit, 12)
  const scenarios = generateSweepScenarios()
  const allResults = scenarios.map(scenario => {
    const result = simulateScenario(scenario, config)
    result.score = scoreDesign(result)
    return result
  })
  const eligible = allResults.filter(meetsProductionBar)
  const results = eligible.length > 0 ? eligible : allResults
  return results.sort(compareDesigns).slice(0, limit)
}

function scoreDesign (result) {
  const availabilityScore = result.availabilityAfterSourceStop * 5
  const commitScore = result.commitRate * 1.5
  const privacyPenalty = result.adversaryReconstructsCiphertext * 16
  const undetectedPenalty = result.undetectedActiveServing * 14
  const activeLeakPenalty = result.activeServingViolation * 0.15
  const costPenalty = result.costUnits * 0.01
  const complexityPenalty = (result.witnessRounds || 0) * 0.03
  return round(availabilityScore + commitScore - privacyPenalty - undetectedPenalty - activeLeakPenalty - costPenalty - complexityPenalty)
}

function meetsProductionBar (result) {
  return result.commitRate >= 0.985 &&
    result.availabilityAfterSourceStop >= 0.995 &&
    result.adversaryReconstructsCiphertext <= 0.005 &&
    result.undetectedActiveServing <= 0.005
}

function compareDesigns (a, b) {
  const delta = conservativeScore(b) - conservativeScore(a)
  if (delta !== 0) return delta
  return a.costUnits - b.costUnits
}

function conservativeScore (result) {
  const thresholdRatio = result.readThreshold / result.custodyCount
  return result.score -
    result.adversaryReconstructsCiphertext * 4 -
    result.undetectedActiveServing * 2 +
    thresholdRatio * 0.05
}

function printSweepReport (config, results) {
  console.log('HiveRelay blind atomic custody optimization sweep')
  console.log(`iterations=${config.iterations} seed=${config.seed}`)
  console.log('')
  console.log([
    pad('rank', 5),
    pad('scenario', 28),
    pad('score', 8),
    pad('avail', 9),
    pad('adv-recon', 11),
    pad('undetected', 12),
    pad('cost', 7),
    pad('watchers', 9)
  ].join(' '))
  console.log('-'.repeat(94))
  for (let i = 0; i < results.length; i++) {
    const r = results[i]
    console.log([
      pad(String(i + 1), 5),
      pad(r.name, 28),
      pad(String(r.score), 8),
      pad(percent(r.availabilityAfterSourceStop), 9),
      pad(percent(r.adversaryReconstructsCiphertext), 11),
      pad(percent(r.undetectedActiveServing), 12),
      pad(String(r.costUnits), 7),
      pad(r.maxWitnessProbes ? `${r.witnessCount}x${r.witnessRounds}` : '0', 9)
    ].join(' '))
  }
  const best = results[0]
  if (best) {
    console.log('')
    console.log('Production bar: commit >= 98.5%, availability >= 99.5%, adversary reconstruction <= 0.5%, undetected serving <= 0.5%')
    console.log('Recommended design from this sweep:')
    console.log(`- ${best.name}: ${best.description}`)
    console.log(`- receipt quorum: ${best.receiptQuorum}/${best.custodyCount}`)
    console.log(`- reconstruction threshold: ${best.readThreshold}/${best.custodyCount}`)
    console.log(`- expiry witnesses: ${best.witnessCount} x ${best.witnessRounds} rotating rounds`)
    console.log(`- risk: ${percent(best.adversaryReconstructsCiphertext)} adversary reconstruction, ${percent(best.undetectedActiveServing)} undetected active serving`)
  }
}

function parseArgs (argv) {
  const parsed = {}
  for (let i = 0; i < argv.length; i++) {
    const arg = argv[i]
    if (!arg.startsWith('--')) continue
    const key = arg.slice(2)
    const next = argv[i + 1]
    if (!next || next.startsWith('--')) {
      parsed[key] = true
    } else {
      parsed[key] = next
      i++
    }
  }
  return parsed
}

function toPositiveInt (value, fallback) {
  const n = Number.parseInt(value, 10)
  return Number.isFinite(n) && n > 0 ? n : fallback
}

function toProbability (value, fallback) {
  if (value === undefined) return fallback
  const n = Number(value)
  return Number.isFinite(n) ? clamp(n, 0, 1) : fallback
}

function hash32 (input) {
  let h = 2166136261
  for (let i = 0; i < input.length; i++) {
    h ^= input.charCodeAt(i)
    h = Math.imul(h, 16777619)
  }
  return h >>> 0
}

function hexFromInt (relayIndex, operatorIndex) {
  return `${relayIndex.toString(16).padStart(8, '0')}${operatorIndex.toString(16).padStart(8, '0')}`.padEnd(64, '0')
}

function ratio (num, den) {
  return round(num / den)
}

function round (n) {
  return Math.round(n * 10000) / 10000
}

function percent (n) {
  return `${(n * 100).toFixed(2)}%`
}

function pad (value, width) {
  const s = String(value)
  return s.length >= width ? s.slice(0, width) : s + ' '.repeat(width - s.length)
}

function clamp (n, min, max) {
  return Math.max(min, Math.min(max, n))
}

class Rng {
  constructor (seed) {
    this.state = seed >>> 0
  }

  next () {
    this.state += 0x6D2B79F5
    let t = this.state
    t = Math.imul(t ^ (t >>> 15), t | 1)
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
  }

  int (max) {
    return Math.floor(this.next() * max)
  }

  shuffle (items) {
    const copy = [...items]
    for (let i = copy.length - 1; i > 0; i--) {
      const j = this.int(i + 1)
      const tmp = copy[i]
      copy[i] = copy[j]
      copy[j] = tmp
    }
    return copy
  }
}

function main () {
  const args = parseArgs(process.argv.slice(2))
  const iterations = toPositiveInt(args.iterations, DEFAULT_ITERATIONS)
  const seed = String(args.seed || DEFAULT_SEED)
  const json = args.json === true
  const sweep = args.sweep === true

  const config = {
    iterations,
    seed,
    relayCount: toPositiveInt(args.relays, 72),
    operatorCount: toPositiveInt(args.operators, 16),
    regionCount: toPositiveInt(args.regions, 7),
    maliciousOperatorRate: toProbability(args.maliciousOperators, 0.16),
    honestBugContinuesServingRate: toProbability(args.honestBugContinuesServing, 0.015),
    maliciousContinuesServingRate: toProbability(args.maliciousContinuesServing, 0.62),
    maliciousCensorRate: toProbability(args.maliciousCensor, 0.35),
    witnessDetectRate: toProbability(args.witnessDetect, 0.84)
  }

  const results = sweep
    ? runSweep(config, { limit: args.limit })
    : SCENARIOS.map(scenario => simulateScenario(scenario, config))

  if (json) {
    console.log(JSON.stringify({ config, mode: sweep ? 'sweep' : 'scenario', results, breakthrough: summarizeBreakthrough(results) }, null, 2))
  } else if (sweep) {
    printSweepReport(config, results)
  } else {
    printReport(config, results)
  }
}

main()