OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / scripts / test-arbitration-panel.js

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

/**
 * test-arbitration-panel.js
 *
 * End-to-end test of the opt-in verifiable arbitrator panel: a real
 * ArbitrationService talking to a real VRFService through a real
 * ServiceRegistry, over a real ReputationSystem pool.
 *
 *   - panels are OFF by default (dispute.panel === null, open voting)
 *   - when enabled, submit draws a reputation-weighted committee via VRF
 *   - the committee is independently reproducible: verify(pubkey, alpha, pi) →
 *     beta → weightedSample(candidates) == members (and via select-verify)
 *   - only panel members may vote; eligible non-members are rejected
 *   - a panel dispute resolves on a panel quorum and slashes as usual
 *   - graceful fallback to open voting when: pool < panel size, or no VRF
 *   - per-dispute override (params.panel true/false) wins over the default
 */

import { ArbitrationService } from '../packages/services/builtin/arbitration-service.js'
import { VRFService } from '../packages/services/builtin/vrf-service.js'
import { ServiceRegistry } from '../packages/core/core/services/registry.js'
import { ReputationSystem } from '../packages/core/incentive/reputation/index.js'
import { weightedSample } from '../packages/services/builtin/vrf/sortition.js'
import { verify, proofToHash, toHex } from '../packages/services/builtin/vrf/ecvrf.js'

let passed = 0
let failed = 0
function assert (cond, label) {
  if (cond) { console.log(`  PASS  ${label}`); passed++ } else { console.log(`  FAIL  ${label}`); failed++ }
}
async function rejectsWith (fn, code, label) {
  try { await fn(); assert(false, label + ' (expected throw)') } catch (e) { assert(String(e.message).includes(code), label + (String(e.message).includes(code) ? '' : ` (got "${e.message}")`)) }
}

function eligibleRecord (score, total, passed, latency) {
  return {
    score, totalChallenges: total, passedChallenges: passed, failedChallenges: total - passed,
    avgLatencyMs: latency, totalBytesServed: 0, totalUptimeHours: 0,
    region: null, geoBonus: false, firstSeen: Date.now(), lastActivity: Date.now()
  }
}

/** Build a node with N eligible arbitrators and an optional VRF service. */
async function makeNode ({ nEligible = 8, withVrf = true, vrfSeed = '11'.repeat(32) } = {}) {
  const reputation = new ReputationSystem()
  for (let i = 0; i < nEligible; i++) {
    // Vary score/reliability a little so weighting has something to bite on.
    reputation.records.set('arb' + i, eligibleRecord(120 + i * 20, 60 + i, 58 + i, 400 + i * 10))
  }
  // An ineligible peer that must never be drawn or allowed to vote.
  reputation.records.set('weak', eligibleRecord(10, 5, 3, 4000))

  const slashed = []
  const published = []
  const registry = new ServiceRegistry()
  const node = {
    reputation,
    serviceRegistry: registry,
    paymentManager: { slash: (pubkey, amount, reason) => slashed.push({ pubkey, amount, reason }) },
    router: { pubsub: { publish: (topic, data) => published.push({ topic, data }) } },
    _slashed: slashed,
    _published: published
  }
  if (withVrf) {
    registry.register(new VRFService({ seed: vrfSeed }))
    await registry.startAll({ node })
  }
  return node
}

async function main () {
  // ── 1. default OFF: no panel, open voting ───────────────────────────────────
  console.log('── default (panels off) ──')
  {
    const node = await makeNode()
    const arb = new ArbitrationService()
    await arb.start({ node })
    const d = await arb.submit({ type: 'sla-violation', respondent: 'relayZ', penalty: 1000 }, { remotePubkey: 'claimantX' })
    assert(d.panel === null, 'panel is null by default')
    // any eligible arbitrator may vote (open voting)
    const v = await arb.vote({ id: d.id, verdict: 'claimant' }, { remotePubkey: 'arb3' })
    assert(v.status === 'voting', 'open voting accepts any eligible arbitrator')
  }

  // ── 2. enabled: a committee is drawn and is reproducible ────────────────────
  console.log('\n── panel enabled (weighted draw) ──')
  const node = await makeNode()
  const arb = new ArbitrationService({ panel: { enabled: true, size: 5, weighted: true } })
  await arb.start({ node })

  const d = await arb.submit({ type: 'sla-violation', respondent: 'relayZ', penalty: 5000 }, { remotePubkey: 'claimantX' })
  assert(d.panel && d.panel.active === true, 'panel formed and active')
  assert(d.panel.members.length === 5, 'committee has the configured size')
  assert(new Set(d.panel.members).size === 5, 'committee members are distinct')
  assert(d.panel.members.every(m => m.startsWith('arb')), 'members come from the eligible pool')
  assert(!d.panel.members.includes('weak'), 'ineligible peer is never drawn')
  assert(!d.panel.members.includes('claimantX') && !d.panel.members.includes('relayZ'), 'parties are excluded from the panel')
  assert(d.panel.pi.length === 160 && d.panel.beta.length === 128, 'panel carries the VRF proof + beta')
  assert(d.minVotes === 3, 'quorum capped to min(minVotes, panelSize)')

  // Reproduce the draw independently from the recorded proof + candidate snapshot.
  assert(verify(d.panel.vrfPubkey, d.panel.alpha, d.panel.pi) === true, 'panel VRF proof verifies under the relay VRF key')
  const betaBytes = proofToHash(d.panel.pi)
  assert(toHex(betaBytes) === d.panel.beta, 'recomputed beta matches the recorded beta')
  const replay = weightedSample({ seed: betaBytes, candidates: d.panel.candidates, count: 5, weighted: true })
  assert(JSON.stringify(replay) === JSON.stringify(d.panel.members), 'third party reproduces the exact committee from beta + candidate snapshot')

  // And via the service's own select-verify capability.
  const sv = await node.serviceRegistry.handleRequest('vrf', 'select-verify', {
    pubkey: d.panel.vrfPubkey, alpha: d.panel.alpha, pi: d.panel.pi,
    candidates: d.panel.candidates, count: 5, weighted: true, committee: d.panel.members
  }, { caller: 'local' })
  assert(sv.valid === true && sv.vrfValid && sv.committeeValid, 'select-verify accepts the committee')

  // determinism: a second dispute with identical immutable content draws the
  // same alpha → but ids are random so alpha differs; instead re-derive on the
  // same dispute object to confirm alpha binding is stable.
  assert(arb._panelAlpha(arb.disputes.get(d.id)) === d.panel.alpha, 'panel alpha is a stable function of dispute content')

  // ── 3. only panel members may vote ──────────────────────────────────────────
  console.log('\n── panel voting gate ──')
  const allArbs = Array.from({ length: 8 }, (_, i) => 'arb' + i)
  const offPanel = allArbs.filter(a => !d.panel.members.includes(a))
  assert(offPanel.length === 3, 'there are eligible arbitrators not on the panel')
  await rejectsWith(() => arb.vote({ id: d.id, verdict: 'claimant' }, { remotePubkey: offPanel[0] }),
    'ARBITRATOR_NOT_ON_PANEL', 'eligible non-member is rejected by the panel gate')
  // ineligible peer still rejected by the eligibility bar first
  await rejectsWith(() => arb.vote({ id: d.id, verdict: 'claimant' }, { remotePubkey: 'weak' }),
    'ARBITRATOR_INELIGIBLE', 'ineligible peer rejected even though panels are active')

  // ── 4. panel resolves + slashes ─────────────────────────────────────────────
  console.log('\n── panel resolution ──')
  const m = d.panel.members
  await arb.vote({ id: d.id, verdict: 'claimant' }, { remotePubkey: m[0] })
  await arb.vote({ id: d.id, verdict: 'claimant' }, { remotePubkey: m[1] })
  const resolved = await arb.vote({ id: d.id, verdict: 'respondent' }, { remotePubkey: m[2] })
  assert(resolved.status === 'resolved', 'panel dispute resolves at quorum')
  assert(resolved.verdict === 'claimant', 'majority verdict recorded')
  assert(node._slashed.length === 1 && node._slashed[0].amount === 5000 && node._slashed[0].pubkey === 'relayZ', 'respondent slashed on claimant win')
  assert(node._published.some(p => p.topic === 'arbitration/submitted' && Array.isArray(p.data.panel)), 'submitted event advertises the panel')

  // ── 5. fallback: pool smaller than panel size ───────────────────────────────
  console.log('\n── fallback: insufficient pool ──')
  {
    const small = await makeNode({ nEligible: 3 })
    const arb2 = new ArbitrationService({ panel: { enabled: true, size: 5 } })
    await arb2.start({ node: small })
    const dd = await arb2.submit({ type: 'proof-failure', respondent: 'relayZ' }, { remotePubkey: 'claimantX' })
    assert(dd.panel && dd.panel.active === false && dd.panel.reason === 'insufficient-eligible-pool', 'small pool falls back (inactive panel with reason)')
    // open voting still works for eligible arbitrators
    const vv = await arb2.vote({ id: dd.id, verdict: 'claimant' }, { remotePubkey: 'arb1' })
    assert(vv.status === 'voting', 'fallback dispute uses open voting')
  }

  // ── 6. fallback: no VRF service ─────────────────────────────────────────────
  console.log('\n── fallback: no VRF service ──')
  {
    const noVrf = await makeNode({ withVrf: false })
    const arb3 = new ArbitrationService({ panel: { enabled: true, size: 5 } })
    await arb3.start({ node: noVrf })
    const dd = await arb3.submit({ type: 'proof-failure', respondent: 'relayZ' }, { remotePubkey: 'claimantX' })
    assert(dd.panel && dd.panel.active === false && String(dd.panel.reason).startsWith('vrf-unavailable'), 'missing VRF service falls back')
  }

  // ── 7. per-dispute override ─────────────────────────────────────────────────
  console.log('\n── per-dispute override ──')
  {
    // service default OFF, but this dispute forces a panel ON
    const n2 = await makeNode()
    const arbOff = new ArbitrationService() // panels off
    await arbOff.start({ node: n2 })
    const forced = await arbOff.submit({ type: 'sla-violation', respondent: 'relayZ', panel: true }, { remotePubkey: 'claimantX' })
    assert(forced.panel && forced.panel.active === true, 'params.panel=true forces a panel even when the service default is off')

    // service default ON, but this dispute forces open voting
    const arbOn = new ArbitrationService({ panel: { enabled: true, size: 5 } })
    await arbOn.start({ node: n2 })
    const opened = await arbOn.submit({ type: 'sla-violation', respondent: 'relayZ', panel: false }, { remotePubkey: 'claimantX' })
    assert(opened.panel === null, 'params.panel=false forces open voting even when the service default is on')
  }

  console.log(`\n${passed} passed, ${failed} failed\n`)
  process.exit(failed === 0 ? 0 : 1)
}

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