/**
* Decentralized Arbitration Service
*
* Peer-adjudicated dispute resolution for the relay network.
* Disputes are submitted with evidence (bandwidth receipts, proof-of-relay
* results, SLA contracts). High-reputation relay nodes vote on the outcome.
* Winners gain reputation; losers are slashed.
*
* Arbitrator eligibility: score > 100, reliability > 0.95, 50+ challenges,
* not a party to the dispute.
*
* ─── Application disputes (poker/*) ─────────────────────────────────────────
* In addition to the original relay-level dispute classes (sla-violation,
* proof-failure, receipt-dispute), this service now accepts a small set of
* application-level dispute types for the poker substrate. These piggyback
* on the same arbitration machinery (submit → vote → resolve → slash) but
* carry app-specific evidence shapes:
*
* 'poker/missing-share' — a player was expected to publish a
* decryption share for a board / hole card by
* tick T, didn't. Evidence: signed-log proof
* that the share is absent past the deadline.
*
* 'poker/invalid-share' — a published share fails the verification
* equation for ciphertext X. Evidence: the
* ciphertext, the bad share, and the falsifying
* witness (everything the verifier needs to
* re-check the failure deterministically).
*
* 'poker/refused-reveal' — a player was in the pot at showdown and did
* not publish their reveal within the timeout
* window. Evidence: signed-log proof of the
* showdown state + the timeout deadline.
*
* The arbitration service does NOT re-run the poker protocol. It accepts
* the structured evidence at submit time, runs a fast schema/well-formedness
* check, and surfaces the result to voters. Voters (relay operators with
* sufficient reputation) verify the cryptographic claim independently before
* casting their vote. The final verdict triggers slashing the respondent's
* stake escrow exactly the same way an SLA violation slashes a relay deposit.
*
* Adding new app dispute classes should follow the same pattern: extend
* VALID_DISPUTE_TYPES, add a verifier in `_appEvidenceVerifiers`, and document
* the evidence shape here.
*
* ─── Verifiable arbitrator panels (opt-in) ──────────────────────────────────
* By default this service uses OPEN voting: any peer that clears the
* eligibility bar may vote, and the first `minVotes` decide. That is simple
* and unchanged.
*
* When `panel.enabled` is set (constructor opts or node config), each new
* dispute instead draws a fixed committee up-front via the VRF service: alpha
* is bound to the immutable dispute content, the relay proves it, and the
* resulting unbiasable `beta` seeds a reputation-WEIGHTED sortition over the
* eligible pool (sortition.js). Only the drawn members may then vote. The
* dispute records the candidate snapshot + proof, so any peer can replay
* verify(vrfPubkey, alpha, pi) → beta → weightedSample(...) and confirm the
* committee was not hand-picked.
*
* Trust note: the VRF holder (the relay) cannot bias WHICH members are drawn
* from a given pool, but — holding the key — it could in principle grind the
* alpha by re-deriving it. We bind alpha to immutable dispute fields to make
* that observable; for adversarial deployments the alpha should additionally
* fold in an external/committee randomness beacon the relay does not control.
* If a panel cannot be formed (VRF unavailable, pool smaller than the panel
* size) the dispute transparently falls back to open voting.
*/
import crypto from 'crypto'
import { ServiceProvider } from 'p2p-hiverelay/core/services/provider.js'
import { quantizeWeights } from './vrf/sortition.js'
const MIN_ARBITRATOR_SCORE = 100
const MIN_ARBITRATOR_RELIABILITY = 0.95
const MIN_ARBITRATOR_CHALLENGES = 50
const DEFAULT_MIN_VOTES = 3
const MIN_VOTES_FLOOR = 3
// Verifiable-panel defaults. Panels are OFF unless explicitly enabled.
const DEFAULT_PANEL_SIZE = 5
const PANEL_VRF_SERVICE = 'vrf'
const PANEL_ALPHA_DOMAIN = 'hiverelay/arb-panel/v1'
// Relay-level disputes — the original three classes, unchanged.
const RELAY_DISPUTE_TYPES = ['sla-violation', 'proof-failure', 'receipt-dispute']
// Application-level disputes for the poker substrate. See the file header
// for evidence shape. Namespaced `poker/*` so other apps can claim their own
// namespaces (`liars-dice/*`, `mafia/*`, etc.) without colliding.
const POKER_DISPUTE_TYPES = [
'poker/missing-share',
'poker/invalid-share',
'poker/refused-reveal'
]
const VALID_DISPUTE_TYPES = [...RELAY_DISPUTE_TYPES, ...POKER_DISPUTE_TYPES]
// Loose upper bounds on evidence size for app disputes. Generous enough to
// fit a reasonable autobase-log proof slice + ciphertext + signature blobs,
// tight enough to refuse griefing-by-megabyte. Tune up if real poker traffic
// shows we need more.
const MAX_APP_EVIDENCE_BYTES = 64 * 1024
// Maximum ciphertext / share byte length for poker evidence. ElGamal-style
// ciphertexts and decryption shares are small (<1 KB even at high security
// levels) — anything bigger is almost certainly malformed.
const MAX_POKER_BLOB_BYTES = 2048
export class ArbitrationService extends ServiceProvider {
constructor (opts = {}) {
super()
this.disputes = new Map() // id -> dispute
this.node = null
this.config = {}
// Panel config from constructor opts; merged with node config in start().
this._panelOpts = opts.panel || null
this.panelConfig = this._resolvePanelConfig(this._panelOpts)
}
/** Merge panel settings from (defaults ← node config ← constructor opts). */
_resolvePanelConfig (...sources) {
const merged = {}
for (const s of sources) { if (s && typeof s === 'object') Object.assign(merged, s) }
return {
enabled: merged.enabled === true,
// A panel must be at least the vote floor or it could never resolve.
size: Math.max(MIN_VOTES_FLOOR, Number(merged.size) || DEFAULT_PANEL_SIZE),
weighted: merged.weighted !== false, // reputation-weighted by default
vrfService: typeof merged.vrfService === 'string' ? merged.vrfService : PANEL_VRF_SERVICE
}
}
manifest () {
return {
name: 'arbitration',
version: '1.0.0',
description: 'Decentralized dispute resolution for relay incentives',
capabilities: ['submit', 'vote', 'get', 'list', 'evidence']
}
}
async start (context) {
this.node = context.node
this.config = context.config || this.config || {}
// Node config may carry arbitration.panel; constructor opts win over it.
const nodePanel = this.config.arbitration?.panel || this.config.arbitrationPanel || null
this.panelConfig = this._resolvePanelConfig(nodePanel, this._panelOpts)
}
async stop () {
this.disputes.clear()
}
/**
* Submit a new dispute.
*
* Relay-level disputes (sla-violation / proof-failure / receipt-dispute)
* carry their classic evidence (receipts / proofResults / slaContract).
*
* Application-level disputes (poker/*) carry their evidence under
* `params.appEvidence` as a single typed object. The shape is validated
* at submit time by `_validateAppEvidence` — malformed evidence rejects
* loudly so the claimant can fix it before votes are wasted on it.
*
* @param {object} params
* @param {string} params.type - One of VALID_DISPUTE_TYPES
* @param {string} params.respondent - Pubkey hex of the accused party
* (relay pubkey for relay disputes; player pubkey for poker disputes)
* @param {object} [params.receipts] - BandwidthReceipt evidence
* @param {object} [params.proofResults] - Proof-of-relay challenge results
* @param {object} [params.slaContract] - SLA contract data
* @param {object} [params.appEvidence] - Typed evidence for application
* disputes. Schema depends on `type`; see the file header.
* @param {number} [params.penalty] - Requested penalty in sats
*/
async submit (params, context) {
if (!params.type || !VALID_DISPUTE_TYPES.includes(params.type)) {
throw new Error('ARBITRATION_INVALID_TYPE')
}
if (!params.respondent || typeof params.respondent !== 'string') {
throw new Error('ARBITRATION_MISSING_RESPONDENT')
}
// App-level disputes MUST carry typed evidence — there's no fallback
// evidence shape that would make sense (you can't slash a player for
// 'missing-share' without a pointer to which share was missing).
// Validate the shape up-front so voters never see malformed cases.
const isAppDispute = POKER_DISPUTE_TYPES.includes(params.type)
if (isAppDispute) {
if (!params.appEvidence || typeof params.appEvidence !== 'object') {
throw new Error('ARBITRATION_MISSING_APP_EVIDENCE')
}
// Throws ARBITRATION_BAD_APP_EVIDENCE with a specific reason on
// shape mismatch. Caught nowhere here — propagates to the caller.
this._validateAppEvidence(params.type, params.appEvidence)
}
const id = crypto.randomBytes(16).toString('hex')
// Use authenticated identity when available, fall back to params for local calls
const claimant = context?.remotePubkey || (context?.caller === 'remote' ? null : params.claimant) || 'local'
if (context?.caller === 'remote' && !context.remotePubkey) {
throw new Error('ARBITRATION_UNAUTHORIZED')
}
if (claimant === params.respondent) {
throw new Error('ARBITRATION_SELF_DISPUTE')
}
const dispute = {
id,
type: params.type,
claimant,
respondent: params.respondent,
evidence: {
receipts: params.receipts || [],
proofResults: params.proofResults || [],
slaContract: params.slaContract || null,
// App-typed evidence lives alongside the relay-typed slots so a
// single dispute can in principle carry both (e.g. a future class
// that mixes relay receipts with a poker share). For now only one
// side is populated per dispute.
appEvidence: isAppDispute ? params.appEvidence : null
},
votes: new Map(),
status: 'open',
verdict: null,
penalty: Math.min(Math.max(0, params.penalty || 0), this.config?.maxPenalty || 1000000),
createdAt: Date.now(),
resolvedAt: null,
minVotes: Math.max(MIN_VOTES_FLOOR, params.minVotes || this.config?.minVotes || DEFAULT_MIN_VOTES),
// Verifiable committee (null when panels are off or selection fell back).
panel: null
}
// Per-dispute override: params.panel === true/false forces panels on/off
// for this dispute regardless of the service default.
const wantPanel = params.panel === true || (params.panel !== false && this.panelConfig.enabled)
if (wantPanel) {
dispute.panel = await this._formPanel(dispute)
// If a committee was actually drawn, cap the quorum to its size so the
// dispute can resolve once enough panellists have voted.
if (dispute.panel?.active) {
dispute.minVotes = Math.min(dispute.minVotes, dispute.panel.members.length)
}
}
this.disputes.set(id, dispute)
this.node?.router?.pubsub?.publish('arbitration/submitted', {
disputeId: id,
type: dispute.type,
respondent: dispute.respondent,
panel: dispute.panel?.active ? dispute.panel.members : null
})
return this._serialize(dispute)
}
/**
* Cast a vote on a dispute.
* @param {object} params
* @param {string} params.id - Dispute ID
* @param {string} params.verdict - 'claimant' or 'respondent'
*/
async vote (params, context) {
const dispute = this.disputes.get(params.id)
if (!dispute) throw new Error('DISPUTE_NOT_FOUND')
if (dispute.status === 'resolved') throw new Error('DISPUTE_ALREADY_RESOLVED')
if (!params.verdict || !['claimant', 'respondent'].includes(params.verdict)) {
throw new Error('ARBITRATION_INVALID_VERDICT')
}
// Remote callers must use their authenticated identity
const voter = context?.caller === 'remote'
? context.remotePubkey
: (params.voterPubkey || context?.remotePubkey)
if (!voter) throw new Error('ARBITRATION_MISSING_VOTER')
// Eligibility check
if (!this._isEligibleArbitrator(voter, dispute)) {
throw new Error('ARBITRATOR_INELIGIBLE')
}
// If a verifiable panel was drawn for this dispute, only its members may
// vote. Open-voting disputes (panel null/inactive) skip this gate.
if (dispute.panel?.active && !dispute.panel.members.includes(voter)) {
throw new Error('ARBITRATOR_NOT_ON_PANEL')
}
if (dispute.votes.has(voter)) {
throw new Error('ARBITRATION_ALREADY_VOTED')
}
// Record vote
dispute.votes.set(voter, params.verdict)
if (dispute.status === 'open') dispute.status = 'voting'
// Check if resolution threshold met
if (dispute.votes.size >= dispute.minVotes) {
this._resolve(dispute)
}
return this._serialize(dispute)
}
/**
* Get a dispute by ID.
*/
async get (params) {
const dispute = this.disputes.get(params.id)
if (!dispute) throw new Error('DISPUTE_NOT_FOUND')
return this._serialize(dispute)
}
/**
* List disputes, optionally filtered.
* @param {object} [params] - { status?, type? }
*/
async list (params = {}) {
const result = []
for (const dispute of this.disputes.values()) {
if (params.status && dispute.status !== params.status) continue
if (params.type && dispute.type !== params.type) continue
result.push(this._serialize(dispute))
}
return result
}
/**
* Verify evidence attached to a dispute.
*
* - Bandwidth receipts are verified via BandwidthReceipt.verify().
* - Poker / app evidence is verified via the per-type verifier in
* `_verifyAppEvidence` and surfaced under `appEvidence`. Verifiers are
* deliberately conservative: a verifier that can't make a determination
* returns `{ verdict: 'inconclusive', reason }` rather than guessing.
* Voters take inconclusive verdicts as a signal to inspect raw evidence
* themselves before voting.
*/
async evidence (params) {
const dispute = this.disputes.get(params.id)
if (!dispute) throw new Error('DISPUTE_NOT_FOUND')
const receiptResults = []
for (const receipt of dispute.evidence.receipts) {
let valid = false
try {
// Dynamic import to avoid hard dep
const { BandwidthReceipt } = await import('../../protocol/bandwidth-receipt.js')
valid = BandwidthReceipt.verify(receipt)
} catch {
valid = false
}
receiptResults.push({ receipt, valid })
}
// App-level evidence: only present on poker/* disputes today. The
// verifier never throws — anything it can't classify becomes an
// 'inconclusive' verdict the voter handles manually.
let appEvidenceResult = null
if (dispute.evidence.appEvidence) {
appEvidenceResult = this._verifyAppEvidence(dispute.type, dispute.evidence.appEvidence)
}
return {
receipts: receiptResults,
proofResults: dispute.evidence.proofResults,
slaContract: dispute.evidence.slaContract,
appEvidence: appEvidenceResult
}
}
// ─── App evidence: schema validation (at submit time) ──────────────────────
//
// These checks are intentionally CHEAP — shape, type, byte budget, basic
// identifier well-formedness. They're not cryptographic. Their purpose is
// to reject obviously-malformed disputes at submission so voters never
// waste a vote on something that can't be evaluated. The actual
// cryptographic re-check is `_verifyAppEvidence`, run at `evidence()` time
// and by voters before they cast their vote.
_validateAppEvidence (type, ae) {
const size = JSON.stringify(ae).length
if (size > MAX_APP_EVIDENCE_BYTES) {
throw new Error('ARBITRATION_BAD_APP_EVIDENCE:oversized')
}
switch (type) {
case 'poker/missing-share':
return this._validateMissingShare(ae)
case 'poker/invalid-share':
return this._validateInvalidShare(ae)
case 'poker/refused-reveal':
return this._validateRefusedReveal(ae)
default:
// Unknown app type — should be unreachable because submit() guards
// on VALID_DISPUTE_TYPES first, but defend in depth.
throw new Error('ARBITRATION_BAD_APP_EVIDENCE:unknown-type')
}
}
/**
* 'poker/missing-share' evidence shape:
*
* {
* tableKey: <hex 64>, // pubkey of the table autobase/log
* handId: <string>, // identifier within the table
* cardIndex: <int>, // position in the shuffled deck the share
* // was for (board card or hole)
* deadline: <ms epoch>, // timestamp by which the share was due
* logProof: <object>, // signed-log slice showing the deadline tick
* // passed without the expected share entry
* }
*/
_validateMissingShare (ae) {
if (!isHexKey(ae.tableKey)) throw new Error('ARBITRATION_BAD_APP_EVIDENCE:tableKey')
if (typeof ae.handId !== 'string' || ae.handId.length === 0 || ae.handId.length > 128) {
throw new Error('ARBITRATION_BAD_APP_EVIDENCE:handId')
}
if (!Number.isInteger(ae.cardIndex) || ae.cardIndex < 0 || ae.cardIndex >= 52) {
throw new Error('ARBITRATION_BAD_APP_EVIDENCE:cardIndex')
}
if (typeof ae.deadline !== 'number' || !Number.isFinite(ae.deadline)) {
throw new Error('ARBITRATION_BAD_APP_EVIDENCE:deadline')
}
if (!ae.logProof || typeof ae.logProof !== 'object') {
throw new Error('ARBITRATION_BAD_APP_EVIDENCE:logProof')
}
}
/**
* 'poker/invalid-share' evidence shape:
*
* {
* tableKey: <hex 64>,
* handId: <string>,
* cardIndex: <int>,
* ciphertext: <hex>, // the deck ciphertext at cardIndex
* share: <hex>, // the respondent's published share
* witness: <object> // the falsifying witness (proof equation inputs)
* }
*/
_validateInvalidShare (ae) {
if (!isHexKey(ae.tableKey)) throw new Error('ARBITRATION_BAD_APP_EVIDENCE:tableKey')
if (typeof ae.handId !== 'string' || ae.handId.length === 0 || ae.handId.length > 128) {
throw new Error('ARBITRATION_BAD_APP_EVIDENCE:handId')
}
if (!Number.isInteger(ae.cardIndex) || ae.cardIndex < 0 || ae.cardIndex >= 52) {
throw new Error('ARBITRATION_BAD_APP_EVIDENCE:cardIndex')
}
if (!isBoundedHex(ae.ciphertext, MAX_POKER_BLOB_BYTES)) {
throw new Error('ARBITRATION_BAD_APP_EVIDENCE:ciphertext')
}
if (!isBoundedHex(ae.share, MAX_POKER_BLOB_BYTES)) {
throw new Error('ARBITRATION_BAD_APP_EVIDENCE:share')
}
if (!ae.witness || typeof ae.witness !== 'object') {
throw new Error('ARBITRATION_BAD_APP_EVIDENCE:witness')
}
}
/**
* 'poker/refused-reveal' evidence shape:
*
* {
* tableKey: <hex 64>,
* handId: <string>,
* showdownDeadline:<ms epoch>,
* potCommitProof: <object>, // signed-log slice showing the respondent
* // was still in the pot at showdown
* logProof: <object>, // signed-log slice showing the deadline
* // tick passed without a reveal entry
* }
*/
_validateRefusedReveal (ae) {
if (!isHexKey(ae.tableKey)) throw new Error('ARBITRATION_BAD_APP_EVIDENCE:tableKey')
if (typeof ae.handId !== 'string' || ae.handId.length === 0 || ae.handId.length > 128) {
throw new Error('ARBITRATION_BAD_APP_EVIDENCE:handId')
}
if (typeof ae.showdownDeadline !== 'number' || !Number.isFinite(ae.showdownDeadline)) {
throw new Error('ARBITRATION_BAD_APP_EVIDENCE:showdownDeadline')
}
if (!ae.potCommitProof || typeof ae.potCommitProof !== 'object') {
throw new Error('ARBITRATION_BAD_APP_EVIDENCE:potCommitProof')
}
if (!ae.logProof || typeof ae.logProof !== 'object') {
throw new Error('ARBITRATION_BAD_APP_EVIDENCE:logProof')
}
}
// ─── App evidence: cryptographic verification (at evidence() time) ─────────
//
// These do the actual claim re-check. They are intentionally pluggable —
// operators can swap in their own verifier via `setAppEvidenceVerifier`
// (e.g. to wire in a project-specific shuffle proof library). The bundled
// verifiers are conservative stubs: they only return a positive verdict
// when they have enough information to be sure. Otherwise they return
// 'inconclusive' and voters fall back to manual review.
_verifyAppEvidence (type, ae) {
const custom = this._appEvidenceVerifiers && this._appEvidenceVerifiers[type]
if (typeof custom === 'function') {
try {
const r = custom(ae)
return normalizeAppVerdict(r)
} catch (err) {
return { verdict: 'inconclusive', reason: 'verifier-threw:' + (err.message || 'unknown') }
}
}
// Default: no verifier registered. We can still surface basic structural
// confirmation so voters know shape validation passed at submit time.
return { verdict: 'inconclusive', reason: 'no-verifier-registered' }
}
/**
* Register a cryptographic verifier for an app dispute type. The verifier
* is called with the typed evidence object and must return either:
* { verdict: 'claim-supported' | 'claim-refuted' | 'inconclusive', reason }
* or a boolean (true → 'claim-supported', false → 'claim-refuted').
*
* Verifiers MUST be deterministic and side-effect-free — they may be
* called by many voters independently and the answers must agree.
*
* @param {string} type - One of POKER_DISPUTE_TYPES (or future namespaces)
* @param {(ae: object) => any} fn
*/
setAppEvidenceVerifier (type, fn) {
if (!POKER_DISPUTE_TYPES.includes(type)) {
throw new Error('ARBITRATION_UNKNOWN_APP_TYPE:' + type)
}
if (typeof fn !== 'function') {
throw new Error('ARBITRATION_BAD_VERIFIER')
}
if (!this._appEvidenceVerifiers) this._appEvidenceVerifiers = {}
this._appEvidenceVerifiers[type] = fn
}
// --- Internal ---
/**
* Derive the VRF input `alpha` for a dispute's panel draw. Bound to immutable
* dispute fields so the proof attests to THIS dispute and the binding is
* auditable. Returns a 32-byte hex string.
*/
_panelAlpha (dispute) {
const h = crypto.createHash('sha256')
for (const part of [
PANEL_ALPHA_DOMAIN,
dispute.id,
dispute.type,
String(dispute.claimant),
String(dispute.respondent),
String(dispute.createdAt)
]) {
h.update(Buffer.from(String(part), 'utf8'))
h.update(Buffer.from([0x00])) // length-independent separator
}
return h.digest('hex')
}
/**
* Draw a verifiable arbitrator committee for a dispute via the VRF service.
* Never throws: any failure (no VRF service, no reputation, pool too small)
* returns an inactive panel with a reason and the dispute falls back to open
* voting. On success returns the proof + candidate snapshot needed to replay
* the draw independently.
*/
async _formPanel (dispute) {
const size = this.panelConfig.size
const reputation = this.node?.reputation
const registry = this.node?.serviceRegistry
if (!registry) return { active: false, reason: 'no-service-registry' }
if (!reputation || !(reputation.records instanceof Map)) {
return { active: false, reason: 'no-reputation' }
}
// Build the eligible candidate pool, excluding the parties. Weight each by
// score x reliability — both are positive for anyone past the eligibility
// bar, so (unlike the full composite, which zeroes out on missing latency)
// every eligible arbitrator keeps a meaningful, drawable weight.
const candidates = []
for (const [pubkey, record] of reputation.records) {
if (pubkey === dispute.claimant || pubkey === dispute.respondent) continue
if (!this._isEligibleArbitrator(pubkey, dispute)) continue
const reliability = record.totalChallenges > 0
? record.passedChallenges / record.totalChallenges
: 0
candidates.push({ id: pubkey, weight: record.score * reliability })
}
if (candidates.length < size) {
return { active: false, reason: 'insufficient-eligible-pool', poolSize: candidates.length, size }
}
// Integer weights for the pure-integer VRF sortition (so prover and any
// verifier agree exactly). Unweighted panels collapse to a uniform draw.
const drawCandidates = this.panelConfig.weighted
? quantizeWeights(candidates)
: candidates.map(c => ({ id: c.id, weight: 1 }))
const alpha = this._panelAlpha(dispute)
let result
try {
result = await registry.handleRequest(this.panelConfig.vrfService, 'select', {
alpha,
candidates: drawCandidates,
count: size,
weighted: this.panelConfig.weighted
}, { caller: 'local' })
} catch (err) {
return { active: false, reason: 'vrf-unavailable:' + (err?.message || 'unknown') }
}
if (!result || !Array.isArray(result.committee) || result.committee.length === 0) {
return { active: false, reason: 'empty-committee' }
}
return {
active: true,
weighted: this.panelConfig.weighted,
size,
poolSize: candidates.length,
vrfService: this.panelConfig.vrfService,
vrfPubkey: result.pubkey,
suite: result.suite,
alpha: result.alpha,
pi: result.pi,
beta: result.beta,
// Snapshot the exact pool + weights used, so the draw is replayable.
candidates: drawCandidates,
members: result.committee
}
}
_isEligibleArbitrator (pubkey, dispute) {
// Cannot be a party to the dispute
if (pubkey === dispute.claimant || pubkey === dispute.respondent) {
return false
}
if (!this.node?.reputation) return false
const record = this.node.reputation.getRecord(pubkey)
if (!record) return false
if (record.score < MIN_ARBITRATOR_SCORE) return false
if (record.totalChallenges < MIN_ARBITRATOR_CHALLENGES) return false
const reliability = this.node.reputation.getReliability(pubkey)
if (reliability < MIN_ARBITRATOR_RELIABILITY) return false
return true
}
_resolve (dispute) {
let claimantVotes = 0
let respondentVotes = 0
const voters = { claimant: [], respondent: [] }
for (const [voter, verdict] of dispute.votes) {
if (verdict === 'claimant') {
claimantVotes++
voters.claimant.push(voter)
} else {
respondentVotes++
voters.respondent.push(voter)
}
}
dispute.verdict = claimantVotes >= respondentVotes ? 'claimant' : 'respondent'
dispute.status = 'resolved'
dispute.resolvedAt = Date.now()
// Slash respondent if claimant wins.
//
// Wrapped in try/catch because:
// 1. For app-level disputes (poker/*) the respondent is a PLAYER
// pubkey, which the relay's paymentManager (focused on relay-to-
// relay incentives) likely doesn't know about — a strict
// paymentManager would throw on an unknown account.
// 2. Even for relay-level disputes a misconfigured paymentManager
// shouldn't be able to block dispute resolution — the verdict has
// already been recorded above, and the arbitration/resolved
// pubsub event below must still fire so downstream subscribers
// (escrow contracts, dashboards, audit log) see the outcome.
//
// Slash failures are surfaced as 'slash-failed' so operators can
// reconcile out-of-band; we explicitly do NOT roll back the verdict.
if (dispute.verdict === 'claimant' && dispute.penalty > 0) {
try {
this.node?.paymentManager?.slash(
dispute.respondent,
dispute.penalty,
`Arbitration ${dispute.id}: ${dispute.type}`
)
} catch (err) {
dispute.slashError = err && err.message ? err.message : 'unknown'
this.node?.router?.pubsub?.publish('arbitration/slash-failed', {
disputeId: dispute.id,
respondent: dispute.respondent,
penalty: dispute.penalty,
error: dispute.slashError
})
}
}
// Reputation adjustments for voters. Same fault-isolation argument as
// the slash above — a misbehaving reputation provider mustn't block
// verdict propagation.
if (this.node?.reputation) {
const winnerSide = voters[dispute.verdict]
const loserSide = dispute.verdict === 'claimant' ? voters.respondent : voters.claimant
try {
for (const voter of winnerSide) this.node.reputation.recordChallenge(voter, true, 0) // +10
for (const voter of loserSide) this.node.reputation.recordChallenge(voter, false, 0) // -20
} catch (err) {
dispute.reputationError = err && err.message ? err.message : 'unknown'
}
}
this.node?.router?.pubsub?.publish('arbitration/resolved', {
disputeId: dispute.id,
verdict: dispute.verdict,
penalty: dispute.penalty,
slashError: dispute.slashError || null,
reputationError: dispute.reputationError || null
})
}
_serialize (dispute) {
return {
...dispute,
votes: [...dispute.votes.entries()].map(([voter, verdict]) => ({ voter, verdict }))
}
}
}
// ─── Module-local helpers ────────────────────────────────────────────────────
function isHexKey (s) {
return typeof s === 'string' && /^[0-9a-f]{64}$/i.test(s)
}
function isBoundedHex (s, maxBytes) {
if (typeof s !== 'string') return false
if (!/^[0-9a-f]+$/i.test(s)) return false
// 2 hex chars per byte.
return s.length > 0 && s.length <= maxBytes * 2
}
function normalizeAppVerdict (r) {
if (r === true) return { verdict: 'claim-supported', reason: 'verifier-true' }
if (r === false) return { verdict: 'claim-refuted', reason: 'verifier-false' }
if (r && typeof r === 'object' && typeof r.verdict === 'string') {
const ok = ['claim-supported', 'claim-refuted', 'inconclusive'].includes(r.verdict)
if (!ok) return { verdict: 'inconclusive', reason: 'verifier-bad-verdict' }
return { verdict: r.verdict, reason: r.reason || '' }
}
return { verdict: 'inconclusive', reason: 'verifier-bad-return' }
}
// Re-export the dispute type sets so consumers (poker app glue, tests, docs
// generators) can introspect without hard-coding the list. Keeps a single
// source of truth on what the arbitration service will accept.
export {
RELAY_DISPUTE_TYPES,
POKER_DISPUTE_TYPES,
VALID_DISPUTE_TYPES,
MAX_APP_EVIDENCE_BYTES,
MAX_POKER_BLOB_BYTES
}