OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / scripts / test-arbitration-poker-schemas.js

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

/**
 * test-arbitration-poker-schemas.js
 *
 * Validates poker dispute schemas added to ArbitrationService:
 *
 *   1. Relay-level disputes still submit + are unaffected.
 *   2. Each poker/* type accepts well-formed evidence.
 *   3. Each poker/* type rejects malformed evidence with a specific reason.
 *   4. submit() rejects poker disputes that omit appEvidence entirely.
 *   5. Oversized appEvidence is rejected.
 *   6. evidence() returns 'inconclusive' when no verifier is registered.
 *   7. evidence() returns the verifier's verdict when one is registered,
 *      normalising booleans and bad-shape returns into 'inconclusive'.
 *   8. setAppEvidenceVerifier guards on type + function shape.
 */

import { ArbitrationService, POKER_DISPUTE_TYPES, RELAY_DISPUTE_TYPES } from '../packages/services/builtin/arbitration-service.js'

let passed = 0
let failed = 0

function assert (condition, label) {
  if (condition) {
    console.log(`  PASS  ${label}`)
    passed++
  } else {
    console.log(`  FAIL  ${label}`)
    failed++
  }
}

async function shouldThrow (label, fn, includes) {
  let threw = false
  let msg = null
  try { await fn() } catch (err) { threw = true; msg = err.message }
  if (!threw) {
    assert(false, label + ' (no throw)')
    return
  }
  if (includes && (!msg || !msg.includes(includes))) {
    assert(false, label + ' (wrong message: ' + msg + ')')
    return
  }
  assert(true, label + ' (' + msg + ')')
}

const TABLE_KEY = 'a'.repeat(64)
const RESPONDENT = 'b'.repeat(64)
const CLAIMANT = 'c'.repeat(64)

function newSvc () {
  const svc = new ArbitrationService()
  // Bypass start() — we don't need the relay node hooks for schema testing.
  // Just give the service a no-op router.pubsub so submit doesn't crash.
  svc.node = { router: { pubsub: { publish () {} } } }
  return svc
}

// ── 1. Relay dispute types unchanged ────────────────────────────────────────
async function testRelayDisputesUnchanged () {
  console.log('\n── 1. Relay-level disputes still submit ──')
  for (const type of RELAY_DISPUTE_TYPES) {
    const svc = newSvc()
    const d = await svc.submit({
      type,
      respondent: RESPONDENT,
      claimant: CLAIMANT,
      receipts: [],
      penalty: 100
    }, { caller: 'local' })
    assert(d && d.type === type, `${type}: submits, no appEvidence required`)
    assert(d.evidence.appEvidence === null, `${type}: appEvidence is null`)
  }
}

// ── 2. Poker disputes accept well-formed evidence ───────────────────────────
async function testPokerWellFormed () {
  console.log('\n── 2. Poker disputes accept well-formed evidence ──')
  const goodEvidence = {
    'poker/missing-share': {
      tableKey: TABLE_KEY,
      handId: 'hand-42',
      cardIndex: 17,
      deadline: 1700000000000,
      logProof: { tick: 12 }
    },
    'poker/invalid-share': {
      tableKey: TABLE_KEY,
      handId: 'hand-42',
      cardIndex: 17,
      ciphertext: 'ab'.repeat(32),
      share: 'cd'.repeat(32),
      witness: { challenge: 'aa', response: 'bb' }
    },
    'poker/refused-reveal': {
      tableKey: TABLE_KEY,
      handId: 'hand-42',
      showdownDeadline: 1700000000000,
      potCommitProof: { tick: 30 },
      logProof: { tick: 34 }
    }
  }
  for (const type of POKER_DISPUTE_TYPES) {
    const svc = newSvc()
    const d = await svc.submit({
      type,
      respondent: RESPONDENT,
      claimant: CLAIMANT,
      appEvidence: goodEvidence[type],
      penalty: 100
    }, { caller: 'local' })
    assert(d.evidence.appEvidence !== null, `${type}: appEvidence carried`)
    assert(d.evidence.appEvidence.tableKey === TABLE_KEY, `${type}: tableKey carried`)
  }
}

// ── 3. Malformed evidence rejected with specific reasons ───────────────────
async function testPokerMalformed () {
  console.log('\n── 3. Malformed poker evidence rejected ──')
  const svc = newSvc()
  await shouldThrow('missing-share bad tableKey', () => svc.submit({
    type: 'poker/missing-share',
    respondent: RESPONDENT,
    claimant: CLAIMANT,
    appEvidence: { tableKey: 'not-hex', handId: 'h', cardIndex: 0, deadline: 1, logProof: {} }
  }, { caller: 'local' }), 'tableKey')

  await shouldThrow('invalid-share bad cardIndex (>=52)', () => svc.submit({
    type: 'poker/invalid-share',
    respondent: RESPONDENT,
    claimant: CLAIMANT,
    appEvidence: {
      tableKey: TABLE_KEY, handId: 'h', cardIndex: 99,
      ciphertext: 'ab', share: 'cd', witness: {}
    }
  }, { caller: 'local' }), 'cardIndex')

  await shouldThrow('invalid-share oversized ciphertext', () => svc.submit({
    type: 'poker/invalid-share',
    respondent: RESPONDENT,
    claimant: CLAIMANT,
    appEvidence: {
      tableKey: TABLE_KEY, handId: 'h', cardIndex: 1,
      ciphertext: 'aa'.repeat(5000), // 5000 bytes > 2048 cap
      share: 'cd', witness: {}
    }
  }, { caller: 'local' }), 'ciphertext')

  await shouldThrow('refused-reveal missing potCommitProof', () => svc.submit({
    type: 'poker/refused-reveal',
    respondent: RESPONDENT,
    claimant: CLAIMANT,
    appEvidence: {
      tableKey: TABLE_KEY, handId: 'h',
      showdownDeadline: 1, logProof: {}
    }
  }, { caller: 'local' }), 'potCommitProof')
}

// ── 4. Poker dispute without appEvidence rejected ───────────────────────────
async function testPokerRequiresAppEvidence () {
  console.log('\n── 4. Poker dispute without appEvidence ──')
  const svc = newSvc()
  await shouldThrow('missing-share: no appEvidence at all', () => svc.submit({
    type: 'poker/missing-share',
    respondent: RESPONDENT,
    claimant: CLAIMANT
  }, { caller: 'local' }), 'MISSING_APP_EVIDENCE')
}

// ── 5. Oversized appEvidence rejected ───────────────────────────────────────
async function testOversized () {
  console.log('\n── 5. Oversized appEvidence ──')
  const svc = newSvc()
  // Build an oversized witness blob (>64 KB serialized).
  const huge = { junk: 'x'.repeat(100000) }
  await shouldThrow('oversized', () => svc.submit({
    type: 'poker/invalid-share',
    respondent: RESPONDENT,
    claimant: CLAIMANT,
    appEvidence: {
      tableKey: TABLE_KEY, handId: 'h', cardIndex: 1,
      ciphertext: 'ab', share: 'cd', witness: huge
    }
  }, { caller: 'local' }), 'oversized')
}

// ── 6. evidence() returns 'inconclusive' when no verifier ───────────────────
async function testEvidenceNoVerifier () {
  console.log('\n── 6. evidence() with no verifier registered ──')
  const svc = newSvc()
  const d = await svc.submit({
    type: 'poker/missing-share',
    respondent: RESPONDENT,
    claimant: CLAIMANT,
    appEvidence: {
      tableKey: TABLE_KEY, handId: 'h', cardIndex: 0, deadline: 1, logProof: {}
    }
  }, { caller: 'local' })
  const e = await svc.evidence({ id: d.id })
  assert(e.appEvidence && e.appEvidence.verdict === 'inconclusive', 'verdict inconclusive')
  assert(e.appEvidence.reason === 'no-verifier-registered', 'reason: no-verifier-registered')
}

// ── 7. evidence() uses registered verifier; normalises return ───────────────
async function testEvidenceWithVerifier () {
  console.log('\n── 7. evidence() with verifier ──')
  // Boolean return.
  let svc = newSvc()
  svc.setAppEvidenceVerifier('poker/missing-share', () => true)
  let d = await svc.submit({
    type: 'poker/missing-share',
    respondent: RESPONDENT,
    claimant: CLAIMANT,
    appEvidence: { tableKey: TABLE_KEY, handId: 'h', cardIndex: 0, deadline: 1, logProof: {} }
  }, { caller: 'local' })
  let e = await svc.evidence({ id: d.id })
  assert(e.appEvidence.verdict === 'claim-supported', 'boolean true → claim-supported')

  svc = newSvc()
  svc.setAppEvidenceVerifier('poker/missing-share', () => false)
  d = await svc.submit({
    type: 'poker/missing-share',
    respondent: RESPONDENT,
    claimant: CLAIMANT,
    appEvidence: { tableKey: TABLE_KEY, handId: 'h', cardIndex: 0, deadline: 1, logProof: {} }
  }, { caller: 'local' })
  e = await svc.evidence({ id: d.id })
  assert(e.appEvidence.verdict === 'claim-refuted', 'boolean false → claim-refuted')

  // Throwing verifier becomes inconclusive (not a crash).
  svc = newSvc()
  svc.setAppEvidenceVerifier('poker/missing-share', () => { throw new Error('boom') })
  d = await svc.submit({
    type: 'poker/missing-share',
    respondent: RESPONDENT,
    claimant: CLAIMANT,
    appEvidence: { tableKey: TABLE_KEY, handId: 'h', cardIndex: 0, deadline: 1, logProof: {} }
  }, { caller: 'local' })
  e = await svc.evidence({ id: d.id })
  assert(e.appEvidence.verdict === 'inconclusive', 'throwing verifier → inconclusive')
  assert(e.appEvidence.reason.startsWith('verifier-threw:'), 'reason mentions verifier threw')

  // Bad-shape verifier return becomes inconclusive.
  svc = newSvc()
  svc.setAppEvidenceVerifier('poker/missing-share', () => ({ verdict: 'lol-no' }))
  d = await svc.submit({
    type: 'poker/missing-share',
    respondent: RESPONDENT,
    claimant: CLAIMANT,
    appEvidence: { tableKey: TABLE_KEY, handId: 'h', cardIndex: 0, deadline: 1, logProof: {} }
  }, { caller: 'local' })
  e = await svc.evidence({ id: d.id })
  assert(e.appEvidence.verdict === 'inconclusive', 'bad-verdict return → inconclusive')
}

// ── 8. setAppEvidenceVerifier guards ────────────────────────────────────────
async function testSetVerifierGuards () {
  console.log('\n── 8. setAppEvidenceVerifier guards ──')
  const svc = newSvc()
  let threw = false
  try { svc.setAppEvidenceVerifier('not-an-app-type', () => true) }
  catch { threw = true }
  assert(threw, 'rejects unknown app type')

  threw = false
  try { svc.setAppEvidenceVerifier('poker/missing-share', 'not-a-fn') }
  catch { threw = true }
  assert(threw, 'rejects non-function')
}

// ── 9. _resolve fault-isolation: slash throws, verdict still propagates ────
async function testResolveFaultIsolation () {
  console.log('\n── 9. _resolve guards: throwing slash / reputation ──')
  const svc = newSvc()

  // Stage a paymentManager that THROWS — simulating "respondent unknown to
  // this payment system", which is the realistic poker-dispute case.
  let pubsubEvents = []
  svc.node = {
    router: { pubsub: { publish: (topic, payload) => pubsubEvents.push({ topic, payload }) } },
    paymentManager: { slash: () => { throw new Error('unknown-account') } }
  }

  // Submit + 3 votes for claimant → triggers _resolve with verdict=claimant.
  const d = await svc.submit({
    type: 'sla-violation',
    respondent: RESPONDENT,
    claimant: CLAIMANT,
    penalty: 100,
    minVotes: 3
  }, { caller: 'local' })

  // Need eligible voters. Stub reputation so the eligibility check passes,
  // and so recordChallenge can be tested in the same setup.
  const goodRep = {
    getRecord: () => ({ score: 200, totalChallenges: 100 }),
    getReliability: () => 0.99,
    recordChallenge: () => {}
  }
  svc.node.reputation = goodRep

  let resolved = null
  try {
    for (const v of ['v1', 'v2', 'v3']) {
      await svc.vote({ id: d.id, verdict: 'claimant' }, { caller: 'remote', remotePubkey: v })
    }
    resolved = await svc.get({ id: d.id })
  } catch (err) {
    assert(false, 'vote() threw despite slash guard: ' + err.message)
    return
  }

  assert(resolved && resolved.status === 'resolved', 'dispute resolved despite slash throw')
  assert(resolved.verdict === 'claimant', 'verdict recorded')
  assert(resolved.slashError === 'unknown-account', 'slashError surfaced')
  const slashFailed = pubsubEvents.find(e => e.topic === 'arbitration/slash-failed')
  const resolvedEv = pubsubEvents.find(e => e.topic === 'arbitration/resolved')
  assert(!!slashFailed, 'arbitration/slash-failed pubsub fired')
  assert(!!resolvedEv, 'arbitration/resolved pubsub still fired (downstream subscribers see verdict)')
  assert(resolvedEv.payload.slashError === 'unknown-account', 'resolved payload carries slashError for downstream')
}

// ── 10. _resolve fault-isolation: recordChallenge throws too ──────────────
async function testReputationFaultIsolation () {
  console.log('\n── 10. _resolve guards: throwing recordChallenge ──')
  const svc = newSvc()
  let pubsubEvents = []
  svc.node = {
    router: { pubsub: { publish: (topic, payload) => pubsubEvents.push({ topic, payload }) } },
    reputation: {
      getRecord: () => ({ score: 200, totalChallenges: 100 }),
      getReliability: () => 0.99,
      recordChallenge: () => { throw new Error('rep-down') }
    }
  }

  const d = await svc.submit({
    type: 'sla-violation', respondent: RESPONDENT, claimant: CLAIMANT, penalty: 0, minVotes: 3
  }, { caller: 'local' })

  let threw = false
  try {
    for (const v of ['v1', 'v2', 'v3']) {
      await svc.vote({ id: d.id, verdict: 'claimant' }, { caller: 'remote', remotePubkey: v })
    }
  } catch (err) { threw = true; console.log('   vote threw:', err.message) }
  assert(!threw, 'vote() did not throw despite reputation throw')

  const resolved = await svc.get({ id: d.id })
  assert(resolved.verdict === 'claimant', 'verdict recorded')
  assert(resolved.reputationError === 'rep-down', 'reputationError surfaced')
  assert(pubsubEvents.some(e => e.topic === 'arbitration/resolved'), 'resolved pubsub still fired')
}

async function main () {
  await testRelayDisputesUnchanged()
  await testPokerWellFormed()
  await testPokerMalformed()
  await testPokerRequiresAppEvidence()
  await testOversized()
  await testEvidenceNoVerifier()
  await testEvidenceWithVerifier()
  await testSetVerifierGuards()
  await testResolveFaultIsolation()
  await testReputationFaultIsolation()
  console.log(`\n── done: ${passed} passed, ${failed} failed ──`)
  process.exit(failed === 0 ? 0 : 1)
}

main().catch((err) => {
  console.error('fatal:', err)
  process.exit(2)
})