OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / packages / core / incentive / reputation / index.js

380 lines · javascript ref:
/**
 * 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()
}