/**
* Reputation System
*
* Tracks relay node reputation based on:
* - Proof-of-relay challenge pass rate
* - Bandwidth receipts collected
* - Uptime (continuous presence on DHT)
* - Geographic diversity contribution
*
* Reputation decays over time without activity.
* Used in Phase 1 (altruistic) and Phase 2 (marketplace) for relay selection.
*/
import { EventEmitter } from 'events'
import { readFile, writeFile, rename, unlink } from 'fs/promises'
const DECAY_RATE = 0.995 // Daily decay multiplier (~0.5% per day)
const CHALLENGE_WEIGHT = 10 // Points per passed challenge
const BANDWIDTH_WEIGHT = 0.001 // Points per MB served
const UPTIME_WEIGHT = 1 // Points per hour of uptime
const GEO_BONUS = 50 // Bonus for underserved region
const MIN_CHALLENGES_FOR_RANKING = 10 // Minimum challenges to be ranked
const FORBIDDEN_RECORD_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
export class ReputationSystem extends EventEmitter {
constructor (opts = {}) {
super()
// relayPubkeyHex -> ReputationRecord
this.records = new Map()
this.persistence = opts.persistence || null
if (this.persistence) {
for (const [key, record] of this.persistence.entries()) {
this.records.set(key, record)
}
}
}
_persist (relayPubkeyHex) {
if (!this.persistence) return
const record = this.records.get(relayPubkeyHex)
if (!record) {
try { this.persistence.delete(relayPubkeyHex) } catch (_) {}
return
}
try {
this.persistence.set(relayPubkeyHex, { ...record })
} catch (err) {
this.emit('persist-error', { relay: relayPubkeyHex, error: err.message })
}
}
/**
* Record a proof-of-relay challenge result
*/
recordChallenge (relayPubkeyHex, passed, latencyMs) {
const record = this._getOrCreate(relayPubkeyHex)
record.totalChallenges++
if (passed) {
record.passedChallenges++
record.score += CHALLENGE_WEIGHT
record.avgLatencyMs = (record.avgLatencyMs * (record.passedChallenges - 1) + latencyMs) / record.passedChallenges
} else {
record.failedChallenges++
record.score -= CHALLENGE_WEIGHT * 2 // Penalty is 2x reward
}
record.lastActivity = Date.now()
this.emit('challenge-recorded', { relay: relayPubkeyHex, passed, score: record.score })
this._persist(relayPubkeyHex)
}
/**
* Record bandwidth served (from verified receipt)
*/
recordBandwidth (relayPubkeyHex, bytesServed) {
const record = this._getOrCreate(relayPubkeyHex)
const mb = bytesServed / (1024 * 1024)
record.totalBytesServed += bytesServed
record.score += mb * BANDWIDTH_WEIGHT
record.lastActivity = Date.now()
this._persist(relayPubkeyHex)
}
/**
* Record uptime heartbeat
*/
recordUptime (relayPubkeyHex, hoursOnline) {
const record = this._getOrCreate(relayPubkeyHex)
record.totalUptimeHours += hoursOnline
record.score += hoursOnline * UPTIME_WEIGHT
record.lastActivity = Date.now()
this._persist(relayPubkeyHex)
}
/**
* Apply geographic diversity bonus
*/
applyGeoBonus (relayPubkeyHex, region) {
const record = this._getOrCreate(relayPubkeyHex)
record.region = region
// Count relays per region
const regionCounts = new Map()
for (const r of this.records.values()) {
if (r.region) {
regionCounts.set(r.region, (regionCounts.get(r.region) || 0) + 1)
}
}
// Bonus for underserved regions (fewer than median)
const counts = [...regionCounts.values()]
const median = counts.sort((a, b) => a - b)[Math.floor(counts.length / 2)] || 1
const regionCount = regionCounts.get(region) || 0
if (regionCount < median) {
record.score += GEO_BONUS
record.geoBonus = true
}
this._persist(relayPubkeyHex)
}
/**
* Apply daily decay to all scores
*/
applyDecay () {
for (const [key, record] of this.records) {
record.score *= DECAY_RATE
if (record.score < 0) record.score = 0
this._persist(key)
}
this.emit('decay-applied')
}
/**
* Get reputation score for a relay
*/
getScore (relayPubkeyHex) {
const record = this.records.get(relayPubkeyHex)
if (!record) return 0
return Math.round(record.score * 100) / 100
}
/**
* Get full record for a relay
*/
getRecord (relayPubkeyHex) {
return this.records.get(relayPubkeyHex) || null
}
/**
* Get reliability (challenge pass rate)
*/
getReliability (relayPubkeyHex) {
const record = this.records.get(relayPubkeyHex)
if (!record || record.totalChallenges === 0) return 0
return record.passedChallenges / record.totalChallenges
}
/**
* Get ranked leaderboard
*/
getLeaderboard (limit = 50) {
return [...this.records.entries()]
.filter(([, r]) => r.totalChallenges >= MIN_CHALLENGES_FOR_RANKING)
.sort(byLeaderboardRank)
.slice(0, limit)
.map(([pubkey, record]) => ({
relay: pubkey,
score: Math.round(record.score * 100) / 100,
reliability: record.totalChallenges > 0
? Math.round((record.passedChallenges / record.totalChallenges) * 100) + '%'
: 'N/A',
avgLatencyMs: Math.round(record.avgLatencyMs),
uptimeHours: Math.round(record.totalUptimeHours),
bytesServed: record.totalBytesServed,
totalChallenges: record.totalChallenges,
passedChallenges: record.passedChallenges,
failedChallenges: record.failedChallenges,
region: record.region || 'unknown',
lastActivity: record.lastActivity,
firstSeen: record.firstSeen
}))
}
/**
* Select best relays for a seed request based on reputation + preferences
*/
selectRelays (count, opts = {}) {
let candidates = [...this.records.entries()]
.filter(([, r]) => r.score > 0 && r.totalChallenges >= MIN_CHALLENGES_FOR_RANKING)
// Filter by region preference
if (opts.geoPreference && opts.geoPreference.length > 0) {
const preferred = candidates.filter(([, r]) => opts.geoPreference.includes(r.region))
if (preferred.length >= count) {
candidates = preferred
}
// If not enough in preferred region, use all candidates
}
// Sort by composite score: reliability * score * (1 / latency).
// Ties are fully deterministic so selection does not depend on Map
// insertion order after import, restart, or federation merge.
candidates.sort((a, b) => byCompositeRank(a, b, this))
return candidates.slice(0, count).map(([pubkey]) => pubkey)
}
_compositeScore (record) {
const reliability = record.totalChallenges > 0
? record.passedChallenges / record.totalChallenges
: 0
const latencyFactor = record.avgLatencyMs > 0
? 1000 / record.avgLatencyMs
: 0
return record.score * reliability * latencyFactor
}
_getOrCreate (relayPubkeyHex) {
let record = this.records.get(relayPubkeyHex)
if (!record) {
record = {
score: 0,
totalChallenges: 0,
passedChallenges: 0,
failedChallenges: 0,
avgLatencyMs: 0,
totalBytesServed: 0,
totalUptimeHours: 0,
region: null,
geoBonus: false,
firstSeen: Date.now(),
lastActivity: Date.now()
}
this.records.set(relayPubkeyHex, record)
}
return record
}
/**
* Save reputation data to a JSON file
*/
async save (filePath) {
const tmpPath = filePath + '.tmp'
try {
const data = this.export()
// Atomic write: write to .tmp then rename (POSIX rename is atomic), so
// a crash mid-write can't leave a truncated reputation.json that
// load() would treat as corrupt and silently reset to empty.
await writeFile(tmpPath, JSON.stringify(data, null, 2))
await rename(tmpPath, filePath)
} catch (err) {
try { await unlink(tmpPath) } catch (_) {}
this.emit('save-error', { filePath, error: err })
}
}
/**
* Load reputation data from a JSON file
* Returns a new ReputationSystem instance with the loaded data
*/
static async load (filePath) {
const system = new ReputationSystem()
try {
const raw = await readFile(filePath, 'utf8')
if (!raw || raw.trim() === '') return system
const data = JSON.parse(raw)
if (data && typeof data === 'object') {
system.import(data)
}
} catch (err) {
// Missing file, empty file, or corrupt JSON — return empty system
if (err.code !== 'ENOENT') {
system.emit('load-error', { filePath, error: err })
}
}
return system
}
/**
* Export all records (for persistence)
*/
export () {
const data = Object.create(null)
for (const [key, record] of [...this.records.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
if (FORBIDDEN_RECORD_KEYS.has(key)) continue
data[key] = { ...record }
}
return data
}
/**
* Import records (from persistence)
*/
import (data) {
if (!data || typeof data !== 'object') return
for (const [key, record] of Object.entries(data)) {
if (FORBIDDEN_RECORD_KEYS.has(key) || typeof key !== 'string' || key.length === 0) continue
const normalized = normalizeRecord(record)
if (!normalized) continue
this.records.set(key, normalized)
}
}
}
function byLeaderboardRank (a, b) {
const ar = a[1]
const br = b[1]
return compareNumberDesc(ar.score, br.score) ||
compareNumberDesc(reliabilityOf(ar), reliabilityOf(br)) ||
compareNumberAsc(ar.avgLatencyMs, br.avgLatencyMs, true) ||
compareNumberDesc(ar.totalBytesServed, br.totalBytesServed) ||
compareNumberDesc(ar.totalUptimeHours, br.totalUptimeHours) ||
a[0].localeCompare(b[0])
}
function byCompositeRank (a, b, system) {
return compareNumberDesc(system._compositeScore(a[1]), system._compositeScore(b[1])) ||
compareNumberDesc(reliabilityOf(a[1]), reliabilityOf(b[1])) ||
compareNumberAsc(a[1].avgLatencyMs, b[1].avgLatencyMs, true) ||
compareNumberDesc(a[1].score, b[1].score) ||
a[0].localeCompare(b[0])
}
function reliabilityOf (record) {
return record.totalChallenges > 0 ? record.passedChallenges / record.totalChallenges : 0
}
function compareNumberDesc (a, b) {
const aa = finiteOr(a, 0)
const bb = finiteOr(b, 0)
return bb - aa
}
function compareNumberAsc (a, b, zeroLast = false) {
const aa = zeroLast && !finiteOr(a, 0) ? Infinity : finiteOr(a, Infinity)
const bb = zeroLast && !finiteOr(b, 0) ? Infinity : finiteOr(b, Infinity)
return aa - bb
}
function normalizeRecord (record) {
if (!record || typeof record !== 'object' || Array.isArray(record)) return null
const totalChallenges = nonNegativeInt(record.totalChallenges)
const passedChallenges = Math.min(nonNegativeInt(record.passedChallenges), totalChallenges)
const failedChallenges = Math.min(nonNegativeInt(record.failedChallenges), Math.max(0, totalChallenges - passedChallenges))
return {
score: nonNegativeNumber(record.score),
totalChallenges,
passedChallenges,
failedChallenges,
avgLatencyMs: nonNegativeNumber(record.avgLatencyMs),
totalBytesServed: nonNegativeNumber(record.totalBytesServed),
totalUptimeHours: nonNegativeNumber(record.totalUptimeHours),
region: typeof record.region === 'string' && record.region ? record.region : null,
geoBonus: record.geoBonus === true,
firstSeen: positiveTimestamp(record.firstSeen),
lastActivity: positiveTimestamp(record.lastActivity)
}
}
function finiteOr (value, fallback) {
return Number.isFinite(value) ? value : fallback
}
function nonNegativeNumber (value) {
const n = Number(value)
return Number.isFinite(n) && n > 0 ? n : 0
}
function nonNegativeInt (value) {
return Math.floor(nonNegativeNumber(value))
}
function positiveTimestamp (value) {
const n = Number(value)
return Number.isFinite(n) && n > 0 ? n : Date.now()
}