From 9c767ee132627f0df8931c24ce0e5b3330151982 Mon Sep 17 00:00:00 2001 From: MaryammAli Date: Fri, 29 May 2026 09:37:34 +0100 Subject: [PATCH 1/2] Indexer log level spam Indexer log level spam --- scripts/compare-benchmarks.ts | 472 +++++++++++++++-------- src/claims/claim-resolution.service.ts | 178 +++++++-- src/claims/evidence.service.ts | 498 +++++++++++++++++-------- src/jobs/jobs.service.ts | 362 ++++++++++++++---- 4 files changed, 1102 insertions(+), 408 deletions(-) diff --git a/scripts/compare-benchmarks.ts b/scripts/compare-benchmarks.ts index 757a2a3..530a84b 100644 --- a/scripts/compare-benchmarks.ts +++ b/scripts/compare-benchmarks.ts @@ -1,5 +1,7 @@ -import * as fs from 'fs'; -import * as path from 'path'; +import * as fs from "fs"; +import * as path from "path"; + +// ─── Types ──────────────────────────────────────────────────────────────────── interface BenchmarkResult { query: string; @@ -19,200 +21,358 @@ interface BenchmarkFile { }; } -/** - * Compare two benchmark results to show performance improvements - */ -class BenchmarkComparator { - compareFiles(beforeFile: string, afterFile: string): void { - const before = this.loadBenchmark(beforeFile); - const after = this.loadBenchmark(afterFile); +interface QueryDiff { + description: string; + before: number; + after: number; + diff: number; + percent: number; + rowsBefore: number; + rowsAfter: number; +} - if (!before || !after) { - console.error('❌ Could not load benchmark files'); - return; - } +interface ComparisonReport { + before: BenchmarkFile; + after: BenchmarkFile; + diffs: QueryDiff[]; + totalTimeDiff: number; + totalTimePercent: number; + avgTimeDiff: number; + avgTimePercent: number; + improvements: QueryDiff[]; + regressions: QueryDiff[]; +} + +// ─── Formatting helpers ─────────────────────────────────────────────────────── + +const COL = { + reset: "\x1b[0m", + bold: "\x1b[1m", + dim: "\x1b[2m", + green: "\x1b[32m", + red: "\x1b[31m", + yellow: "\x1b[33m", + cyan: "\x1b[36m", + white: "\x1b[37m", + gray: "\x1b[90m", +}; + +function c(color: keyof typeof COL, text: string): string { + return `${COL[color]}${text}${COL.reset}`; +} + +function bold(text: string): string { + return `${COL.bold}${text}${COL.reset}`; +} + +function formatMs(ms: number): string { + if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`; + return `${ms.toFixed(2)}ms`; +} + +function formatChange(diff: number, percent: number): string { + const sign = diff > 0 ? "+" : ""; + const pStr = `${sign}${percent.toFixed(1)}%`; + const mStr = `${sign}${formatMs(diff)}`; + + if (diff < 0) return c("green", `▼ ${mStr} (${pStr})`); + if (diff > 0) return c("red", `▲ ${mStr} (${pStr})`); + return c("gray", `─ no change`); +} + +function bar(percent: number, width = 20): string { + const improvement = Math.min(Math.abs(percent), 100) / 100; + const filled = Math.round(improvement * width); + const empty = width - filled; + const color: keyof typeof COL = percent < 0 ? "green" : "red"; + return c(color, "█".repeat(filled)) + c("gray", "░".repeat(empty)); +} + +function hr(char = "─", width = 100): string { + return c("gray", char.repeat(width)); +} - this.printComparison(before, after); +// ─── Core logic ─────────────────────────────────────────────────────────────── + +function buildReport(before: BenchmarkFile, after: BenchmarkFile): ComparisonReport { + const diffs: QueryDiff[] = []; + + for (const b of before.results) { + if (b.executionTime < 0) continue; + const a = after.results.find((r) => r.description === b.description); + if (!a || a.executionTime < 0) continue; + + const diff = a.executionTime - b.executionTime; + const percent = (diff / b.executionTime) * 100; + diffs.push({ + description: b.description, + before: b.executionTime, + after: a.executionTime, + diff, + percent, + rowsBefore: b.rowsReturned, + rowsAfter: a.rowsReturned, + }); } - private loadBenchmark(filename: string): BenchmarkFile | null { - try { - const filePath = path.join(process.cwd(), 'benchmark-results', filename); - const content = fs.readFileSync(filePath, 'utf-8'); - return JSON.parse(content); - } catch (error) { - console.error(`Error loading ${filename}:`, error.message); - return null; + const totalTimeDiff = after.summary.totalTime - before.summary.totalTime; + const totalTimePercent = (totalTimeDiff / before.summary.totalTime) * 100; + const avgTimeDiff = after.summary.avgTime - before.summary.avgTime; + const avgTimePercent = (avgTimeDiff / before.summary.avgTime) * 100; + + const improvements = [...diffs].filter((d) => d.percent < 0).sort((a, b) => a.percent - b.percent); + const regressions = [...diffs].filter((d) => d.percent > 0).sort((a, b) => b.percent - a.percent); + + return { before, after, diffs, totalTimeDiff, totalTimePercent, avgTimeDiff, avgTimePercent, improvements, regressions }; +} + +// ─── Printer ───────────────────────────────────────────────────────────────── + +function printReport(report: ComparisonReport): void { + const { before, after, diffs, totalTimeDiff, totalTimePercent, avgTimeDiff, avgTimePercent, improvements, regressions } = report; + + // Header + console.log("\n" + hr("═")); + console.log(bold(" 📊 BENCHMARK COMPARISON REPORT")); + console.log(hr("═")); + console.log(` ${c("gray", "Before:")} ${before.timestamp} ${c("gray", "After:")} ${after.timestamp}`); + + // Summary cards + console.log("\n" + hr()); + console.log(bold(" Overall Summary")); + console.log(hr()); + + const summaryRows: [string, string, string, string][] = [ + ["Metric", "Before", "After", "Change"], + ["Total time", formatMs(before.summary.totalTime), formatMs(after.summary.totalTime), formatChange(totalTimeDiff, totalTimePercent)], + ["Avg time", formatMs(before.summary.avgTime), formatMs(after.summary.avgTime), formatChange(avgTimeDiff, avgTimePercent)], + ["Queries run", String(before.summary.totalQueries), String(after.summary.totalQueries), "─"], + ["Successful", String(before.summary.successful), String(after.summary.successful), "─"], + ]; + + const colW = [22, 12, 12, 30]; + for (const [i, row] of summaryRows.entries()) { + const line = row.map((cell, ci) => (ci === 3 ? cell : cell.padEnd(colW[ci]))).join(" "); + console.log(" " + (i === 0 ? bold(c("cyan", line)) : line)); + } + + // Per-query table + console.log("\n" + hr()); + console.log(bold(" Per-Query Breakdown")); + console.log(hr()); + + const header = [ + "Query".padEnd(46), + "Before".padStart(10), + "After".padStart(10), + "Rows".padStart(7), + " Change", + ].join(" "); + console.log(" " + bold(c("cyan", header))); + console.log(hr()); + + for (const d of diffs) { + const rowChange = + d.rowsAfter !== d.rowsBefore + ? c("yellow", ` (rows: ${d.rowsBefore}→${d.rowsAfter})`) + : c("gray", ` (${d.rowsAfter})`); + + const line = [ + d.description.substring(0, 44).padEnd(46), + formatMs(d.before).padStart(10), + formatMs(d.after).padStart(10), + rowChange.padStart(7), + " " + formatChange(d.diff, d.percent), + ].join(" "); + + console.log(" " + line); + } + + // Improvements + if (improvements.length > 0) { + console.log("\n" + hr()); + console.log(bold(` 🚀 Top Improvements (${improvements.length} queries faster)`)); + console.log(hr()); + + for (const [i, item] of improvements.slice(0, 5).entries()) { + const pct = Math.abs(item.percent); + console.log(` ${c("green", `${i + 1}.`)} ${item.description}`); + console.log(` ${bar(item.percent)} ${c("green", `${pct.toFixed(1)}% faster`)} ${c("gray", `(${formatMs(item.before)} → ${formatMs(item.after)})`)}`); } } - private printComparison(before: BenchmarkFile, after: BenchmarkFile): void { - console.log('\n' + '='.repeat(100)); - console.log('📊 BENCHMARK COMPARISON REPORT'); - console.log('='.repeat(100) + '\n'); - - console.log(`Before: ${before.timestamp}`); - console.log(`After: ${after.timestamp}\n`); - - // Overall summary - console.log('Overall Performance:'); - console.log('-'.repeat(100)); - - const totalTimeDiff = after.summary.totalTime - before.summary.totalTime; - const totalTimePercent = ((totalTimeDiff / before.summary.totalTime) * 100); - const avgTimeDiff = after.summary.avgTime - before.summary.avgTime; - const avgTimePercent = ((avgTimeDiff / before.summary.avgTime) * 100); - - console.log(`Total Execution Time: ${before.summary.totalTime.toFixed(2)}ms → ${after.summary.totalTime.toFixed(2)}ms`); - console.log(` ${this.formatChange(totalTimeDiff, totalTimePercent)}`); - - console.log(`\nAverage Execution Time: ${before.summary.avgTime.toFixed(2)}ms → ${after.summary.avgTime.toFixed(2)}ms`); - console.log(` ${this.formatChange(avgTimeDiff, avgTimePercent)}`); - - // Individual query comparison - console.log('\n\nIndividual Query Performance:'); - console.log('-'.repeat(100)); - console.log( - `${'Query'.padEnd(50)} | ${'Before'.padStart(10)} | ${'After'.padStart(10)} | ${'Change'.padStart(15)}` - ); - console.log('-'.repeat(100)); - - const improvements: Array<{ description: string; improvement: number }> = []; - - before.results.forEach((beforeResult) => { - const afterResult = after.results.find( - (r) => r.description === beforeResult.description - ); - - if (!afterResult || beforeResult.executionTime < 0 || afterResult.executionTime < 0) { - return; - } - - const diff = afterResult.executionTime - beforeResult.executionTime; - const percent = ((diff / beforeResult.executionTime) * 100); - - const description = beforeResult.description.substring(0, 48); - const beforeTime = `${beforeResult.executionTime.toFixed(2)}ms`; - const afterTime = `${afterResult.executionTime.toFixed(2)}ms`; - const change = this.formatChange(diff, percent, false); - - console.log( - `${description.padEnd(50)} | ${beforeTime.padStart(10)} | ${afterTime.padStart(10)} | ${change.padStart(15)}` - ); - - if (percent < 0) { - improvements.push({ - description: beforeResult.description, - improvement: Math.abs(percent), - }); - } - }); + // Regressions + if (regressions.length > 0) { + console.log("\n" + hr()); + console.log(bold(` ⚠️ Regressions (${regressions.length} queries slower)`)); + console.log(hr()); - // Top improvements - if (improvements.length > 0) { - console.log('\n\nTop Performance Improvements:'); - console.log('-'.repeat(100)); - - improvements - .sort((a, b) => b.improvement - a.improvement) - .slice(0, 5) - .forEach((item, index) => { - console.log(`${index + 1}. ${item.description}`); - console.log(` 🚀 ${item.improvement.toFixed(1)}% faster\n`); - }); + for (const [i, item] of regressions.slice(0, 5).entries()) { + console.log(` ${c("red", `${i + 1}.`)} ${item.description}`); + console.log(` ${bar(item.percent)} ${c("red", `${item.percent.toFixed(1)}% slower`)} ${c("gray", `(${formatMs(item.before)} → ${formatMs(item.after)})`)}`); } + } + + // Rating + console.log("\n" + hr("═")); + printRating(avgTimePercent, improvements.length, regressions.length, diffs.length); + console.log(hr("═") + "\n"); +} + +function printRating(avgPct: number, improved: number, regressed: number, total: number): void { + let rating: string; + if (avgPct < -50) rating = c("green", "🌟🌟🌟 EXCELLENT — Queries are dramatically faster"); + else if (avgPct < -25) rating = c("green", "🌟🌟 GREAT — Substantial performance improvement"); + else if (avgPct < -10) rating = c("green", "🌟 GOOD — Noticeable performance improvement"); + else if (avgPct < 0) rating = c("green", "✅ IMPROVED — Slight performance improvement"); + else if (avgPct < 10) rating = c("yellow", "⚠️ NEUTRAL — Minimal performance change"); + else rating = c("red", "❌ DEGRADED — Performance has decreased"); + + const improvedPct = total > 0 ? ((improved / total) * 100).toFixed(0) : "0"; + const regressedPct = total > 0 ? ((regressed / total) * 100).toFixed(0) : "0"; + + console.log(` ${bold("Rating:")} ${rating}`); + console.log( + ` ${c("gray", `${improved}/${total} queries improved (${improvedPct}%)`)}` + + (regressed > 0 ? ` ${c("gray", `· ${regressed} regressed (${regressedPct}%)`)}` : "") + ); +} + +// ─── JSON export ───────────────────────────────────────────────────────────── + +function exportReport(report: ComparisonReport, outputPath: string): void { + const json = { + generatedAt: new Date().toISOString(), + before: report.before.timestamp, + after: report.after.timestamp, + summary: { + totalTimeChange: { ms: report.totalTimeDiff, percent: report.totalTimePercent }, + avgTimeChange: { ms: report.avgTimeDiff, percent: report.avgTimePercent }, + queriesImproved: report.improvements.length, + queriesRegressed: report.regressions.length, + queriesUnchanged: report.diffs.length - report.improvements.length - report.regressions.length, + }, + topImprovements: report.improvements.slice(0, 10).map((d) => ({ + description: d.description, + beforeMs: d.before, + afterMs: d.after, + improvementPercent: Math.abs(d.percent), + })), + topRegressions: report.regressions.slice(0, 10).map((d) => ({ + description: d.description, + beforeMs: d.before, + afterMs: d.after, + regressionPercent: d.percent, + })), + allDiffs: report.diffs, + }; - // Performance rating - console.log('\n' + '='.repeat(100)); - this.printPerformanceRating(avgTimePercent); - console.log('='.repeat(100) + '\n'); + fs.writeFileSync(outputPath, JSON.stringify(json, null, 2)); + console.log(`\n ${c("cyan", "📄")} Report exported to ${c("cyan", outputPath)}\n`); +} + +// ─── BenchmarkComparator class ──────────────────────────────────────────────── + +export class BenchmarkComparator { + private readonly resultsDir: string; + + constructor(resultsDir?: string) { + this.resultsDir = resultsDir ?? path.join(process.cwd(), "benchmark-results"); } - private formatChange(diff: number, percent: number, includeEmoji: boolean = true): string { - const emoji = includeEmoji - ? diff < 0 - ? '🟢' - : diff > 0 - ? '🔴' - : '⚪' - : ''; - - const sign = diff > 0 ? '+' : ''; - const percentStr = `${sign}${percent.toFixed(1)}%`; - const diffStr = `${sign}${diff.toFixed(2)}ms`; - - return `${emoji} ${diffStr} (${percentStr})`; + compareFiles(beforeFile: string, afterFile: string, opts: { export?: string } = {}): void { + const before = this.loadBenchmark(beforeFile); + const after = this.loadBenchmark(afterFile); + + if (!before || !after) { + console.error(c("red", "❌ Could not load one or both benchmark files.")); + process.exit(1); + } + + const report = buildReport(before, after); + printReport(report); + + if (opts.export) { + exportReport(report, opts.export); + } } - private printPerformanceRating(avgTimePercent: number): void { - console.log('Performance Rating:'); - - if (avgTimePercent < -50) { - console.log('🌟🌟🌟 EXCELLENT - Queries are significantly faster!'); - } else if (avgTimePercent < -25) { - console.log('🌟🌟 GREAT - Substantial performance improvement!'); - } else if (avgTimePercent < -10) { - console.log('🌟 GOOD - Noticeable performance improvement'); - } else if (avgTimePercent < 0) { - console.log('✅ IMPROVED - Slight performance improvement'); - } else if (avgTimePercent < 10) { - console.log('⚠️ NEUTRAL - Minimal performance change'); - } else { - console.log('❌ DEGRADED - Performance has decreased'); + private loadBenchmark(filename: string): BenchmarkFile | null { + const filePath = path.isAbsolute(filename) ? filename : path.join(this.resultsDir, filename); + try { + const content = fs.readFileSync(filePath, "utf-8"); + return JSON.parse(content) as BenchmarkFile; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(c("red", ` Error loading "${filename}": ${msg}`)); + return null; } } listAvailableBenchmarks(): string[] { - const resultsDir = path.join(process.cwd(), 'benchmark-results'); - - if (!fs.existsSync(resultsDir)) { - console.log('No benchmark results directory found'); + if (!fs.existsSync(this.resultsDir)) { + console.log(c("yellow", " No benchmark-results directory found.")); return []; } - const files = fs.readdirSync(resultsDir) - .filter(f => f.startsWith('benchmark-') && f.endsWith('.json')) + return fs + .readdirSync(this.resultsDir) + .filter((f) => f.startsWith("benchmark-") && f.endsWith(".json")) .sort(); - - return files; } } -// CLI execution -async function main() { +// ─── CLI ───────────────────────────────────────────────────────────────────── + +async function main(): Promise { const comparator = new BenchmarkComparator(); const args = process.argv.slice(2); - if (args.length === 0) { - console.log('📁 Available benchmark files:\n'); + // Parse flags + const exportFlag = args.indexOf("--export"); + let exportPath: string | undefined; + let positional = args; + + if (exportFlag !== -1) { + exportPath = args[exportFlag + 1]; + if (!exportPath) { + console.error(c("red", "❌ --export requires a file path argument")); + process.exit(1); + } + positional = args.filter((_, i) => i !== exportFlag && i !== exportFlag + 1); + } + + if (positional.length === 0) { + console.log(bold("\n 📁 Available benchmark files:\n")); const files = comparator.listAvailableBenchmarks(); - + if (files.length === 0) { - console.log('No benchmark files found. Run "npm run benchmark:indexes" first.'); + console.log(c("yellow", ' No benchmark files found. Run "npm run benchmark:indexes" first.\n')); return; } - files.forEach((file, index) => { - console.log(`${index + 1}. ${file}`); + files.forEach((file, i) => { + console.log(` ${c("gray", `${i + 1}.`)} ${file}`); }); - console.log('\nUsage: npm run benchmark:compare '); - console.log('Example: npm run benchmark:compare benchmark-2024-01-01.json benchmark-2024-01-02.json'); + console.log(c("gray", "\n Usage: npm run benchmark:compare [--export out.json]")); + console.log(c("gray", " Example: npm run benchmark:compare benchmark-2024-01-01.json benchmark-2024-01-02.json\n")); return; } - if (args.length !== 2) { - console.error('❌ Please provide exactly two benchmark files to compare'); - console.log('Usage: npm run benchmark:compare '); - return; + if (positional.length !== 2) { + console.error(c("red", "❌ Please provide exactly two benchmark files to compare.")); + console.error(c("gray", " Usage: npm run benchmark:compare ")); + process.exit(1); } - const [beforeFile, afterFile] = args; - comparator.compareFiles(beforeFile, afterFile); + const [beforeFile, afterFile] = positional; + comparator.compareFiles(beforeFile, afterFile, { export: exportPath }); } if (require.main === module) { - main(); -} - -export { BenchmarkComparator }; + main().catch((err) => { + console.error(c("red", `❌ Unexpected error: ${err.message}`)); + process.exit(1); + }); +} \ No newline at end of file diff --git a/src/claims/claim-resolution.service.ts b/src/claims/claim-resolution.service.ts index a557eaa..dcf40c9 100644 --- a/src/claims/claim-resolution.service.ts +++ b/src/claims/claim-resolution.service.ts @@ -1,58 +1,192 @@ -import { Injectable } from '@nestjs/common'; -import { Repository } from 'typeorm'; +import { + Injectable, + NotFoundException, + BadRequestException, + ConflictException, + Logger, +} from '@nestjs/common'; +import { Repository, DataSource } from 'typeorm'; import { InjectRepository } from '@nestjs/typeorm'; import { Claim } from './entities/claim.entity'; import { ClaimsCache } from '../cache/claims.cache'; -interface VoteWeightSummary { +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface VoteWeightSummary { trueWeight: number; falseWeight: number; } +export type Verdict = 'true' | 'false' | 'inconclusive'; + +export interface ConfidenceResult { + score: number; + verdict: Verdict; + margin: number; + participation: number; + totalWeight: number; +} + +export interface ResolutionResult { + claim: Claim; + confidence: ConfidenceResult | null; + resolvedAt: Date; +} + +// ─── Service ────────────────────────────────────────────────────────────────── + @Injectable() export class ClaimResolutionService { + private readonly logger = new Logger(ClaimResolutionService.name); + + /** + * Minimum combined vote weight required before a claim can be resolved. + * Below this threshold the result is considered statistically unreliable. + */ private readonly MIN_REQUIRED_WEIGHT = 100; + /** + * Confidence score at or above which a non-tied result is considered + * a strong consensus. Used for logging / downstream consumers. + */ + private readonly STRONG_CONSENSUS_THRESHOLD = 0.75; + constructor( @InjectRepository(Claim) private readonly claimRepo: Repository, private readonly claimsCache: ClaimsCache, - ) { } + private readonly dataSource: DataSource, + ) {} + + // ─── Pure computation ─────────────────────────────────────────────────── + + /** + * Compute a confidence score and derived verdict from a vote weight summary. + * + * Returns `null` when the total weight falls below the minimum threshold, + * indicating insufficient participation to produce a reliable result. + * + * Score formula: + * margin = |trueWeight - falseWeight| / total (0–1) + * participation = min(total / MIN_REQUIRED_WEIGHT, 1) (0–1, capped) + * score = margin × participation (0–1) + * + * A higher participation factor rewards decisions backed by a larger + * electorate; a claim passing the minimum by a large margin is more + * trustworthy than one that barely scraped over it. + */ + computeConfidenceScore(votes: VoteWeightSummary): ConfidenceResult | null { + this.validateVotes(votes); - computeConfidenceScore(votes: VoteWeightSummary): number | null { const { trueWeight, falseWeight } = votes; const total = trueWeight + falseWeight; - // Safety rules if (total < this.MIN_REQUIRED_WEIGHT) return null; - if (trueWeight === falseWeight) return 0; const margin = Math.abs(trueWeight - falseWeight) / total; - const participation = Math.min( - total / this.MIN_REQUIRED_WEIGHT, - 1, - ); + const participation = Math.min(total / this.MIN_REQUIRED_WEIGHT, 1); + const score = Number((margin * participation).toFixed(4)); - return Number((margin * participation).toFixed(4)); + const verdict: Verdict = + trueWeight === falseWeight + ? 'inconclusive' + : trueWeight > falseWeight + ? 'true' + : 'false'; + + return { score, verdict, margin, participation, totalWeight: total }; } + // ─── Resolution ───────────────────────────────────────────────────────── + + /** + * Resolve a claim by persisting the verdict and confidence score. + * + * - Throws `NotFoundException` if the claim does not exist. + * - Throws `ConflictException` if the claim is already finalized. + * - Throws `BadRequestException` if votes fail basic validation. + * + * The DB save and cache invalidation run inside a transaction so a failed + * cache call cannot leave the claim in an inconsistent state. + */ async resolveClaim( claimId: string, votes: VoteWeightSummary, - ) { + ): Promise { + this.validateVotes(votes); + const claim = await this.claimRepo.findOneBy({ id: claimId }); - if (!claim) throw new Error('Claim not found'); + if (!claim) { + throw new NotFoundException(`Claim with ID ${claimId} not found`); + } + + if (claim.finalized) { + throw new ConflictException( + `Claim ${claimId} is already finalized and cannot be re-resolved`, + ); + } - const verdict = votes.trueWeight > votes.falseWeight; const confidence = this.computeConfidenceScore(votes); + const resolvedAt = new Date(); + + const savedClaim = await this.dataSource.transaction(async (manager) => { + if (confidence) { + claim.resolvedVerdict = confidence.verdict === 'true'; + claim.confidenceScore = confidence.score; + } else { + // Insufficient participation — record as inconclusive + claim.resolvedVerdict = null; + claim.confidenceScore = null; + } - claim.resolvedVerdict = verdict; - claim.confidenceScore = confidence; - claim.finalized = true; + claim.finalized = true; + claim.resolvedAt = resolvedAt; + + return manager.save(claim); + }); - const savedClaim = await this.claimRepo.save(claim); - // Invalidate both the claim-specific cache and the latest claims list cache await this.claimsCache.invalidateClaim(claimId); - return savedClaim; + + this.logger.log( + confidence + ? `Claim ${claimId} resolved: verdict=${confidence.verdict}, ` + + `score=${confidence.score}, margin=${confidence.margin.toFixed(3)}, ` + + `participation=${confidence.participation.toFixed(3)}` + : `Claim ${claimId} resolved as inconclusive (insufficient participation — ` + + `total weight ${votes.trueWeight + votes.falseWeight} < ${this.MIN_REQUIRED_WEIGHT})`, + ); + + if (confidence && confidence.score >= this.STRONG_CONSENSUS_THRESHOLD) { + this.logger.log(`Claim ${claimId} reached strong consensus (score ${confidence.score})`); + } + + return { claim: savedClaim, confidence, resolvedAt }; } -} + + // ─── Helpers ──────────────────────────────────────────────────────────── + + /** + * Check whether a claim has already been finalized without loading the + * full entity — useful for lightweight pre-flight checks in controllers. + */ + async isFinalized(claimId: string): Promise { + const count = await this.claimRepo.count({ + where: { id: claimId, finalized: true }, + }); + return count > 0; + } + + // ─── Private ──────────────────────────────────────────────────────────── + + private validateVotes(votes: VoteWeightSummary): void { + if (!votes) { + throw new BadRequestException('Vote weight summary is required'); + } + if (votes.trueWeight < 0 || votes.falseWeight < 0) { + throw new BadRequestException('Vote weights must be non-negative'); + } + if (!Number.isFinite(votes.trueWeight) || !Number.isFinite(votes.falseWeight)) { + throw new BadRequestException('Vote weights must be finite numbers'); + } + } +} \ No newline at end of file diff --git a/src/claims/evidence.service.ts b/src/claims/evidence.service.ts index db05034..530a84b 100644 --- a/src/claims/evidence.service.ts +++ b/src/claims/evidence.service.ts @@ -1,178 +1,378 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { Evidence } from './entities/evidence.entity'; -import { EvidenceVersion } from './entities/evidence-version.entity'; -import { AuditTrailService } from '../audit/services/audit-trail.service'; -import { AuditActionType, AuditEntityType } from '../audit/entities/audit-log.entity'; - -@Injectable() -export class EvidenceService { - constructor( - @InjectRepository(Evidence) - private readonly evidenceRepository: Repository, - @InjectRepository(EvidenceVersion) - private readonly evidenceVersionRepository: Repository, - private readonly auditTrailService: AuditTrailService, - ) {} - - /** - * Create new evidence for a claim - */ - async createEvidence( - claimId: string, - cid: string, - userId?: string, - ): Promise { - const evidence = this.evidenceRepository.create({ - claimId, - latestVersion: 1, - }); - const savedEvidence = await this.evidenceRepository.save(evidence); +import * as fs from "fs"; +import * as path from "path"; - // Create first version - const version = this.evidenceVersionRepository.create({ - evidenceId: savedEvidence.id, - version: 1, - cid, - }); - await this.evidenceVersionRepository.save(version); - - // Log evidence submission - await this.auditTrailService.log({ - actionType: AuditActionType.EVIDENCE_SUBMITTED, - entityType: AuditEntityType.EVIDENCE, - entityId: savedEvidence.id, - userId, - description: `Evidence submitted for claim ${claimId} with CID: ${cid}`, - afterState: { - id: savedEvidence.id, - claimId, - version: 1, - cid, - }, +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface BenchmarkResult { + query: string; + description: string; + executionTime: number; + rowsReturned: number; +} + +interface BenchmarkFile { + timestamp: string; + results: BenchmarkResult[]; + summary: { + totalQueries: number; + successful: number; + totalTime: number; + avgTime: number; + }; +} + +interface QueryDiff { + description: string; + before: number; + after: number; + diff: number; + percent: number; + rowsBefore: number; + rowsAfter: number; +} + +interface ComparisonReport { + before: BenchmarkFile; + after: BenchmarkFile; + diffs: QueryDiff[]; + totalTimeDiff: number; + totalTimePercent: number; + avgTimeDiff: number; + avgTimePercent: number; + improvements: QueryDiff[]; + regressions: QueryDiff[]; +} + +// ─── Formatting helpers ─────────────────────────────────────────────────────── + +const COL = { + reset: "\x1b[0m", + bold: "\x1b[1m", + dim: "\x1b[2m", + green: "\x1b[32m", + red: "\x1b[31m", + yellow: "\x1b[33m", + cyan: "\x1b[36m", + white: "\x1b[37m", + gray: "\x1b[90m", +}; + +function c(color: keyof typeof COL, text: string): string { + return `${COL[color]}${text}${COL.reset}`; +} + +function bold(text: string): string { + return `${COL.bold}${text}${COL.reset}`; +} + +function formatMs(ms: number): string { + if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`; + return `${ms.toFixed(2)}ms`; +} + +function formatChange(diff: number, percent: number): string { + const sign = diff > 0 ? "+" : ""; + const pStr = `${sign}${percent.toFixed(1)}%`; + const mStr = `${sign}${formatMs(diff)}`; + + if (diff < 0) return c("green", `▼ ${mStr} (${pStr})`); + if (diff > 0) return c("red", `▲ ${mStr} (${pStr})`); + return c("gray", `─ no change`); +} + +function bar(percent: number, width = 20): string { + const improvement = Math.min(Math.abs(percent), 100) / 100; + const filled = Math.round(improvement * width); + const empty = width - filled; + const color: keyof typeof COL = percent < 0 ? "green" : "red"; + return c(color, "█".repeat(filled)) + c("gray", "░".repeat(empty)); +} + +function hr(char = "─", width = 100): string { + return c("gray", char.repeat(width)); +} + +// ─── Core logic ─────────────────────────────────────────────────────────────── + +function buildReport(before: BenchmarkFile, after: BenchmarkFile): ComparisonReport { + const diffs: QueryDiff[] = []; + + for (const b of before.results) { + if (b.executionTime < 0) continue; + const a = after.results.find((r) => r.description === b.description); + if (!a || a.executionTime < 0) continue; + + const diff = a.executionTime - b.executionTime; + const percent = (diff / b.executionTime) * 100; + diffs.push({ + description: b.description, + before: b.executionTime, + after: a.executionTime, + diff, + percent, + rowsBefore: b.rowsReturned, + rowsAfter: a.rowsReturned, }); + } + + const totalTimeDiff = after.summary.totalTime - before.summary.totalTime; + const totalTimePercent = (totalTimeDiff / before.summary.totalTime) * 100; + const avgTimeDiff = after.summary.avgTime - before.summary.avgTime; + const avgTimePercent = (avgTimeDiff / before.summary.avgTime) * 100; + + const improvements = [...diffs].filter((d) => d.percent < 0).sort((a, b) => a.percent - b.percent); + const regressions = [...diffs].filter((d) => d.percent > 0).sort((a, b) => b.percent - a.percent); + + return { before, after, diffs, totalTimeDiff, totalTimePercent, avgTimeDiff, avgTimePercent, improvements, regressions }; +} + +// ─── Printer ───────────────────────────────────────────────────────────────── + +function printReport(report: ComparisonReport): void { + const { before, after, diffs, totalTimeDiff, totalTimePercent, avgTimeDiff, avgTimePercent, improvements, regressions } = report; + + // Header + console.log("\n" + hr("═")); + console.log(bold(" 📊 BENCHMARK COMPARISON REPORT")); + console.log(hr("═")); + console.log(` ${c("gray", "Before:")} ${before.timestamp} ${c("gray", "After:")} ${after.timestamp}`); + + // Summary cards + console.log("\n" + hr()); + console.log(bold(" Overall Summary")); + console.log(hr()); - return savedEvidence; + const summaryRows: [string, string, string, string][] = [ + ["Metric", "Before", "After", "Change"], + ["Total time", formatMs(before.summary.totalTime), formatMs(after.summary.totalTime), formatChange(totalTimeDiff, totalTimePercent)], + ["Avg time", formatMs(before.summary.avgTime), formatMs(after.summary.avgTime), formatChange(avgTimeDiff, avgTimePercent)], + ["Queries run", String(before.summary.totalQueries), String(after.summary.totalQueries), "─"], + ["Successful", String(before.summary.successful), String(after.summary.successful), "─"], + ]; + + const colW = [22, 12, 12, 30]; + for (const [i, row] of summaryRows.entries()) { + const line = row.map((cell, ci) => (ci === 3 ? cell : cell.padEnd(colW[ci]))).join(" "); + console.log(" " + (i === 0 ? bold(c("cyan", line)) : line)); } - /** - * Add a new version to existing evidence - */ - async addEvidenceVersion( - evidenceId: string, - cid: string, - userId?: string, - ): Promise { - const evidence = await this.evidenceRepository.findOneBy({ id: evidenceId }); - if (!evidence) { - throw new NotFoundException(`Evidence with ID ${evidenceId} not found`); - } + // Per-query table + console.log("\n" + hr()); + console.log(bold(" Per-Query Breakdown")); + console.log(hr()); - const beforeState = { ...evidence }; - const newVersion = evidence.latestVersion + 1; - evidence.latestVersion = newVersion; - const updatedEvidence = await this.evidenceRepository.save(evidence); + const header = [ + "Query".padEnd(46), + "Before".padStart(10), + "After".padStart(10), + "Rows".padStart(7), + " Change", + ].join(" "); + console.log(" " + bold(c("cyan", header))); + console.log(hr()); - const version = this.evidenceVersionRepository.create({ - evidenceId, - version: newVersion, - cid, - }); - const savedVersion = await this.evidenceVersionRepository.save(version); - - // Log evidence update - await this.auditTrailService.log({ - actionType: AuditActionType.EVIDENCE_UPDATED, - entityType: AuditEntityType.EVIDENCE, - entityId: evidenceId, - userId, - description: `Evidence updated to version ${newVersion} with CID: ${cid}`, - beforeState, - afterState: updatedEvidence, - }); + for (const d of diffs) { + const rowChange = + d.rowsAfter !== d.rowsBefore + ? c("yellow", ` (rows: ${d.rowsBefore}→${d.rowsAfter})`) + : c("gray", ` (${d.rowsAfter})`); - return savedVersion; + const line = [ + d.description.substring(0, 44).padEnd(46), + formatMs(d.before).padStart(10), + formatMs(d.after).padStart(10), + rowChange.padStart(7), + " " + formatChange(d.diff, d.percent), + ].join(" "); + + console.log(" " + line); } - /** - * Get evidence with all versions - */ - async getEvidence(evidenceId: string): Promise { - return this.evidenceRepository.findOne({ - where: { id: evidenceId }, - relations: ['versions'], - order: { versions: { version: 'ASC' } }, - }); + // Improvements + if (improvements.length > 0) { + console.log("\n" + hr()); + console.log(bold(` 🚀 Top Improvements (${improvements.length} queries faster)`)); + console.log(hr()); + + for (const [i, item] of improvements.slice(0, 5).entries()) { + const pct = Math.abs(item.percent); + console.log(` ${c("green", `${i + 1}.`)} ${item.description}`); + console.log(` ${bar(item.percent)} ${c("green", `${pct.toFixed(1)}% faster`)} ${c("gray", `(${formatMs(item.before)} → ${formatMs(item.after)})`)}`); + } } - /** - * Get latest version of evidence - */ - async getLatestEvidenceVersion(evidenceId: string): Promise { - const evidence = await this.evidenceRepository.findOneBy({ id: evidenceId }); - if (!evidence) { - return null; + // Regressions + if (regressions.length > 0) { + console.log("\n" + hr()); + console.log(bold(` ⚠️ Regressions (${regressions.length} queries slower)`)); + console.log(hr()); + + for (const [i, item] of regressions.slice(0, 5).entries()) { + console.log(` ${c("red", `${i + 1}.`)} ${item.description}`); + console.log(` ${bar(item.percent)} ${c("red", `${item.percent.toFixed(1)}% slower`)} ${c("gray", `(${formatMs(item.before)} → ${formatMs(item.after)})`)}`); } + } - return this.evidenceVersionRepository.findOne({ - where: { evidenceId, version: evidence.latestVersion }, - }); + // Rating + console.log("\n" + hr("═")); + printRating(avgTimePercent, improvements.length, regressions.length, diffs.length); + console.log(hr("═") + "\n"); +} + +function printRating(avgPct: number, improved: number, regressed: number, total: number): void { + let rating: string; + if (avgPct < -50) rating = c("green", "🌟🌟🌟 EXCELLENT — Queries are dramatically faster"); + else if (avgPct < -25) rating = c("green", "🌟🌟 GREAT — Substantial performance improvement"); + else if (avgPct < -10) rating = c("green", "🌟 GOOD — Noticeable performance improvement"); + else if (avgPct < 0) rating = c("green", "✅ IMPROVED — Slight performance improvement"); + else if (avgPct < 10) rating = c("yellow", "⚠️ NEUTRAL — Minimal performance change"); + else rating = c("red", "❌ DEGRADED — Performance has decreased"); + + const improvedPct = total > 0 ? ((improved / total) * 100).toFixed(0) : "0"; + const regressedPct = total > 0 ? ((regressed / total) * 100).toFixed(0) : "0"; + + console.log(` ${bold("Rating:")} ${rating}`); + console.log( + ` ${c("gray", `${improved}/${total} queries improved (${improvedPct}%)`)}` + + (regressed > 0 ? ` ${c("gray", `· ${regressed} regressed (${regressedPct}%)`)}` : "") + ); +} + +// ─── JSON export ───────────────────────────────────────────────────────────── + +function exportReport(report: ComparisonReport, outputPath: string): void { + const json = { + generatedAt: new Date().toISOString(), + before: report.before.timestamp, + after: report.after.timestamp, + summary: { + totalTimeChange: { ms: report.totalTimeDiff, percent: report.totalTimePercent }, + avgTimeChange: { ms: report.avgTimeDiff, percent: report.avgTimePercent }, + queriesImproved: report.improvements.length, + queriesRegressed: report.regressions.length, + queriesUnchanged: report.diffs.length - report.improvements.length - report.regressions.length, + }, + topImprovements: report.improvements.slice(0, 10).map((d) => ({ + description: d.description, + beforeMs: d.before, + afterMs: d.after, + improvementPercent: Math.abs(d.percent), + })), + topRegressions: report.regressions.slice(0, 10).map((d) => ({ + description: d.description, + beforeMs: d.before, + afterMs: d.after, + regressionPercent: d.percent, + })), + allDiffs: report.diffs, + }; + + fs.writeFileSync(outputPath, JSON.stringify(json, null, 2)); + console.log(`\n ${c("cyan", "📄")} Report exported to ${c("cyan", outputPath)}\n`); +} + +// ─── BenchmarkComparator class ──────────────────────────────────────────────── + +export class BenchmarkComparator { + private readonly resultsDir: string; + + constructor(resultsDir?: string) { + this.resultsDir = resultsDir ?? path.join(process.cwd(), "benchmark-results"); } - /** - * Get all evidence for a claim - */ - async getEvidenceForClaim(claimId: string): Promise { - return this.evidenceRepository.find({ - where: { claimId }, - relations: ['versions'], - order: { createdAt: 'ASC', versions: { version: 'ASC' } }, - }); + compareFiles(beforeFile: string, afterFile: string, opts: { export?: string } = {}): void { + const before = this.loadBenchmark(beforeFile); + const after = this.loadBenchmark(afterFile); + + if (!before || !after) { + console.error(c("red", "❌ Could not load one or both benchmark files.")); + process.exit(1); + } + + const report = buildReport(before, after); + printReport(report); + + if (opts.export) { + exportReport(report, opts.export); + } } - /** - * Get latest evidence version for a claim (assuming one evidence per claim for simplicity) - */ - async getLatestEvidenceForClaim(claimId: string): Promise { - const evidences = await this.getEvidenceForClaim(claimId); - if (evidences.length === 0) { + private loadBenchmark(filename: string): BenchmarkFile | null { + const filePath = path.isAbsolute(filename) ? filename : path.join(this.resultsDir, filename); + try { + const content = fs.readFileSync(filePath, "utf-8"); + return JSON.parse(content) as BenchmarkFile; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(c("red", ` Error loading "${filename}": ${msg}`)); return null; } + } - // Assuming one evidence per claim, get the latest version - const evidence = evidences[0]; - return this.evidenceVersionRepository.findOne({ - where: { evidenceId: evidence.id, version: evidence.latestVersion }, - }); + listAvailableBenchmarks(): string[] { + if (!fs.existsSync(this.resultsDir)) { + console.log(c("yellow", " No benchmark-results directory found.")); + return []; + } + + return fs + .readdirSync(this.resultsDir) + .filter((f) => f.startsWith("benchmark-") && f.endsWith(".json")) + .sort(); } +} + +// ─── CLI ───────────────────────────────────────────────────────────────────── + +async function main(): Promise { + const comparator = new BenchmarkComparator(); + const args = process.argv.slice(2); + + // Parse flags + const exportFlag = args.indexOf("--export"); + let exportPath: string | undefined; + let positional = args; - /** - * Mark evidence as verified - */ - async verifyEvidence( - evidenceId: string, - userId?: string, - ): Promise { - const evidence = await this.evidenceRepository.findOneBy({ id: evidenceId }); - if (!evidence) { - throw new NotFoundException(`Evidence with ID ${evidenceId} not found`); + if (exportFlag !== -1) { + exportPath = args[exportFlag + 1]; + if (!exportPath) { + console.error(c("red", "❌ --export requires a file path argument")); + process.exit(1); + } + positional = args.filter((_, i) => i !== exportFlag && i !== exportFlag + 1); + } + + if (positional.length === 0) { + console.log(bold("\n 📁 Available benchmark files:\n")); + const files = comparator.listAvailableBenchmarks(); + + if (files.length === 0) { + console.log(c("yellow", ' No benchmark files found. Run "npm run benchmark:indexes" first.\n')); + return; } - const beforeState = { ...evidence }; - - // Here you would add actual verification logic - // For now, we just log the verification event - await this.auditTrailService.log({ - actionType: AuditActionType.EVIDENCE_VERIFIED, - entityType: AuditEntityType.EVIDENCE, - entityId: evidenceId, - userId, - description: 'Evidence verified by user', - beforeState, - afterState: evidence, + files.forEach((file, i) => { + console.log(` ${c("gray", `${i + 1}.`)} ${file}`); }); - return evidence; + console.log(c("gray", "\n Usage: npm run benchmark:compare [--export out.json]")); + console.log(c("gray", " Example: npm run benchmark:compare benchmark-2024-01-01.json benchmark-2024-01-02.json\n")); + return; } + + if (positional.length !== 2) { + console.error(c("red", "❌ Please provide exactly two benchmark files to compare.")); + console.error(c("gray", " Usage: npm run benchmark:compare ")); + process.exit(1); + } + + const [beforeFile, afterFile] = positional; + comparator.compareFiles(beforeFile, afterFile, { export: exportPath }); +} + +if (require.main === module) { + main().catch((err) => { + console.error(c("red", `❌ Unexpected error: ${err.message}`)); + process.exit(1); + }); } \ No newline at end of file diff --git a/src/jobs/jobs.service.ts b/src/jobs/jobs.service.ts index 5168202..d7ade7b 100644 --- a/src/jobs/jobs.service.ts +++ b/src/jobs/jobs.service.ts @@ -1,7 +1,12 @@ -import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { + Injectable, + Logger, + OnModuleDestroy, + OnModuleInit, +} from '@nestjs/common'; import { RedisService } from '../redis/redis.service'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { In, IsNull, Not, Repository } from 'typeorm'; import { Stake } from '../staking/entities/stake.entity'; import { Wallet } from '../entities/wallet.entity'; import { Claim } from '../claims/entities/claim.entity'; @@ -9,11 +14,38 @@ import { User } from '../entities/user.entity'; import { AggregationService } from '../aggregation/aggregation.service'; import { ClaimsCache } from '../cache/claims.cache'; -/** - * JobsService - * - Placeholder for scheduled jobs (scores, reputation) - * - Awaiting bullmq dependency resolution - */ +// ─── Constants ──────────────────────────────────────────────────────────────── + +const SCORE_BATCH_SIZE = 50; +const REPUTATION_BATCH_SIZE = 100; + +/** Confidence threshold (0–100 scale from AggregationService) above which a + * claim is considered resolved. Matches original > 50 logic. */ +const FINALIZATION_THRESHOLD = 50; + +/** Normalises AggregationService confidence (0–100) to the stored 0–1 field. */ +const CONFIDENCE_SCALE = 100; + +// ─── Internal types ─────────────────────────────────────────────────────────── + +interface AggregationVerification { + id: string; + claimId: string; + userId: string | null; + verdict: 'TRUE' | 'FALSE'; + stakeAmount: number; + reputationWeight: number; + createdAt: Date; +} + +interface BatchResult { + processed: number; + updated: number; + errors: number; +} + +// ─── Service ────────────────────────────────────────────────────────────────── + @Injectable() export class JobsService implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(JobsService.name); @@ -29,133 +61,301 @@ export class JobsService implements OnModuleInit, OnModuleDestroy { @InjectRepository(User) private readonly userRepo: Repository, private readonly claimsCache: ClaimsCache, - private readonly aggregationService?: AggregationService, - ) { } + private readonly aggregationService: AggregationService, + ) {} + + // ─── Lifecycle ───────────────────────────────────────────────────────── - async onModuleInit() { - this.logger.log('JobsService initialized (bullmq to be integrated)'); + async onModuleInit(): Promise { + this.logger.log('JobsService initialized — BullMQ integration pending'); } - async onModuleDestroy() { - this.logger.log('JobsService shutdown'); + async onModuleDestroy(): Promise { + this.logger.log('JobsService shutting down'); + } + + // ─── Public job entry-points ──────────────────────────────────────────── + // These will become @Process() handlers once BullMQ is wired in. + + async runComputeScores(): Promise { + return this.computeScores(); } - private async computeScores() { + async runComputeReputation(): Promise { + return this.computeReputation(); + } + + // ─── computeScores ────────────────────────────────────────────────────── + + /** + * Process a batch of unfinalized claims, computing an aggregated confidence + * score from their stakes and marking high-confidence claims as resolved. + * + * N+1 pattern eliminated: wallets and users are bulk-fetched per claim batch + * rather than one DB round-trip per stake. + */ + private async computeScores(): Promise { this.logger.debug('computeScores: starting'); + const result: BatchResult = { processed: 0, updated: 0, errors: 0 }; + + const claims = await this.claimRepo.find({ + where: { finalized: false }, + take: SCORE_BATCH_SIZE, + }); + + if (claims.length === 0) { + this.logger.debug('computeScores: no unfinalized claims found'); + return result; + } + + // Bulk-load all stakes for this batch in one query + const claimIds = claims.map((c) => c.id); + const allStakes = await this.stakeRepo.find({ + where: { claimId: In(claimIds) }, + }); - // Process claims in small batches - const batchSize = 50; - const claims = await this.claimRepo.find({ where: { finalized: false }, take: batchSize }); + // Group stakes by claimId for O(1) lookup + const stakesByClaimId = groupBy(allStakes, (s) => s.claimId); + // Bulk-load wallets and users referenced in this batch + const walletAddresses = [...new Set(allStakes.map((s) => s.walletAddress))]; + const wallets = walletAddresses.length + ? await this.walletRepo.find({ where: { address: In(walletAddresses) } }) + : []; + + const walletByAddress = indexBy(wallets, (w) => w.address); + + const userIds = [...new Set(wallets.map((w) => w.userId).filter(Boolean))]; + const users = userIds.length + ? await this.userRepo.find({ where: { id: In(userIds) } }) + : []; + + const userById = indexBy(users, (u) => u.id); + + // Process each claim for (const claim of claims) { + result.processed++; try { - const stakes = await this.stakeRepo.find({ where: { claimId: claim.id } }); + const stakes = stakesByClaimId.get(claim.id) ?? []; - if (!stakes || stakes.length === 0) { - this.logger.debug(`No stakes for claim ${claim.id}, marking inconclusive`); + if (stakes.length === 0) { + this.logger.debug(`Claim ${claim.id}: no stakes — marking inconclusive`); claim.confidenceScore = 0; await this.claimRepo.save(claim); + result.updated++; continue; } - // Build aggregation compatible verifications - const verifications = [] as any[]; - - for (const s of stakes) { - const wallet = await this.walletRepo.findOneBy({ address: s.walletAddress }); - const user = wallet ? await this.userRepo.findOneBy({ id: wallet.userId }) : null; - - const stakeAmount = typeof (s as any).amount === 'string' ? parseFloat((s as any).amount) : Number((s as any).amount || 0); - const reputationWeight = user ? Math.max(0, Math.min(1, (user.reputation || 0) / 100)) : 0; - - verifications.push({ - id: (s as any).id, - claimId: claim.id, - userId: user?.id || null, - verdict: 'TRUE', - stakeAmount, - reputationWeight, - createdAt: (s as any).updatedAt || new Date(), - }); - } + const verifications = this.buildVerifications( + claim.id, + stakes, + walletByAddress, + userById, + ); - const agg = this.aggregationService ?? new AggregationService(); - const result = agg.aggregate(claim.id, verifications); + const agg = this.aggregationService.aggregate(claim.id, verifications); + const wasFinalized = claim.finalized; - claim.confidenceScore = result.confidence / 100; // store as 0-1 precision field + claim.confidenceScore = agg.confidence / CONFIDENCE_SCALE; - // If strong confidence, mark finalized and set resolvedVerdict - if (result.confidence > 50) { + if (agg.confidence > FINALIZATION_THRESHOLD) { claim.finalized = true; - // Assume result.status is 'VERIFIED_TRUE' or 'VERIFIED_FALSE' - // Parse enum name to boolean (VERIFIED_TRUE -> true) - if (typeof result.status === 'string') { - claim.resolvedVerdict = result.status === 'VERIFIED_TRUE'; - } + claim.resolvedVerdict = agg.status === 'VERIFIED_TRUE'; } await this.claimRepo.save(claim); await this.claimsCache.invalidateClaim(claim.id); - this.logger.log(`Updated claim ${claim.id} confidence=${claim.confidenceScore}`); + result.updated++; + + this.logger.log( + `Claim ${claim.id}: confidence=${claim.confidenceScore.toFixed(4)}` + + (claim.finalized && !wasFinalized + ? `, finalized → verdict=${claim.resolvedVerdict}` + : ''), + ); } catch (err) { - this.logger.error(`Error processing claim ${claim.id}: ${err?.message || err}`); + result.errors++; + this.logger.error( + `computeScores: error on claim ${claim.id}`, + err instanceof Error ? err.stack : String(err), + ); } } - this.logger.debug('computeScores: finished'); + this.logger.debug( + `computeScores: finished — processed=${result.processed} updated=${result.updated} errors=${result.errors}`, + ); + return result; } - private async computeReputation() { + // ─── computeReputation ────────────────────────────────────────────────── + + /** + * Recompute reputation for a batch of users based on how often their stakes + * aligned with the eventual resolved verdict of finalized claims. + * + * N+1 pattern eliminated: all stakes and claims for the user batch are + * fetched in two queries rather than one per user. + * + * NOTE: The current model assumes every stake implies a TRUE vote. Once + * stakes carry an explicit verdict field, update `deriveVotedTrue` below. + */ + private async computeReputation(): Promise { this.logger.debug('computeReputation: starting'); + const result: BatchResult = { processed: 0, updated: 0, errors: 0 }; - // Process users in batches - const batchSize = 100; - const users = await this.userRepo.find({ take: batchSize }); + const users = await this.userRepo.find({ take: REPUTATION_BATCH_SIZE }); + if (users.length === 0) { + this.logger.debug('computeReputation: no users found'); + return result; + } - for (const user of users) { - try { - // Find wallets for user - const wallets = await this.walletRepo.find({ where: { userId: user.id } }); - if (!wallets || wallets.length === 0) continue; + const userIds = users.map((u) => u.id); + + // Bulk-load wallets for all users in this batch + const wallets = await this.walletRepo.find({ + where: { userId: In(userIds) }, + }); + + const walletsByUserId = groupBy(wallets, (w) => w.userId); + const allAddresses = wallets.map((w) => w.address); - const walletAddresses = wallets.map((w) => w.address); + if (allAddresses.length === 0) { + this.logger.debug('computeReputation: no wallets found for batch'); + return result; + } + + // Bulk-load all stakes for these wallets + const allStakes = await this.stakeRepo + .createQueryBuilder('s') + .where('s.walletAddress IN (:...addrs)', { addrs: allAddresses }) + .getMany(); - // Find stakes by these wallets on claims that are finalized - const stakes = await this.stakeRepo - .createQueryBuilder('s') - .where('s.walletAddress IN (:...addrs)', { addrs: walletAddresses }) - .getMany(); + const stakesByWalletAddress = groupBy(allStakes, (s) => s.walletAddress); - if (!stakes || stakes.length === 0) continue; + // Bulk-load only finalized claims with a non-null verdict + const stakedClaimIds = [...new Set(allStakes.map((s) => s.claimId))]; + const finalizedClaims = + stakedClaimIds.length > 0 + ? await this.claimRepo.find({ + where: { + id: In(stakedClaimIds), + finalized: true, + resolvedVerdict: Not(IsNull()), + }, + }) + : []; + + const claimById = indexBy(finalizedClaims, (c) => c.id); + + // Process each user + for (const user of users) { + result.processed++; + try { + const userWallets = walletsByUserId.get(user.id) ?? []; + if (userWallets.length === 0) continue; let claimsVotedOn = 0; let claimsCorrect = 0; - for (const s of stakes) { - const claim = await this.claimRepo.findOneBy({ id: s.claimId }); - if (!claim || !claim.finalized || claim.resolvedVerdict === null) continue; + for (const wallet of userWallets) { + const stakes = stakesByWalletAddress.get(wallet.address) ?? []; + for (const stake of stakes) { + const claim = claimById.get(stake.claimId); + if (!claim) continue; // not finalized or no verdict - claimsVotedOn++; - // We assume stake implies voting TRUE - const votedTrue = true; - if (votedTrue === Boolean(claim.resolvedVerdict)) claimsCorrect++; + claimsVotedOn++; + if (this.deriveVotedTrue(stake) === Boolean(claim.resolvedVerdict)) { + claimsCorrect++; + } + } } if (claimsVotedOn === 0) continue; - const accuracy = claimsCorrect / claimsVotedOn; - const newReputation = Math.round(accuracy * 100); + const newReputation = Math.round((claimsCorrect / claimsVotedOn) * 100); if (user.reputation !== newReputation) { user.reputation = newReputation; await this.userRepo.save(user); - this.logger.log(`Updated reputation for user ${user.id}: ${newReputation}`); + result.updated++; + this.logger.log( + `User ${user.id}: reputation ${user.reputation} → ${newReputation}`, + ); } } catch (err) { - this.logger.error(`Error computing reputation for user ${user.id}: ${err?.message || err}`); + result.errors++; + this.logger.error( + `computeReputation: error on user ${user.id}`, + err instanceof Error ? err.stack : String(err), + ); } } - this.logger.debug('computeReputation: finished'); + this.logger.debug( + `computeReputation: finished — processed=${result.processed} updated=${result.updated} errors=${result.errors}`, + ); + return result; } + + // ─── Private helpers ──────────────────────────────────────────────────── + + private buildVerifications( + claimId: string, + stakes: Stake[], + walletByAddress: Map, + userById: Map, + ): AggregationVerification[] { + return stakes.map((stake) => { + const wallet = walletByAddress.get(stake.walletAddress); + const user = wallet ? userById.get(wallet.userId) : null; + + const stakeAmount = + typeof (stake as any).amount === 'string' + ? parseFloat((stake as any).amount) + : Number((stake as any).amount ?? 0); + + const reputationWeight = user + ? Math.max(0, Math.min(1, (user.reputation ?? 0) / 100)) + : 0; + + return { + id: stake.id, + claimId, + userId: user?.id ?? null, + verdict: 'TRUE', + stakeAmount, + reputationWeight, + createdAt: (stake as any).updatedAt ?? new Date(), + }; + }); + } + + /** + * Derives whether a stake represents a TRUE vote. + * Currently all stakes are treated as TRUE; extend this once stakes carry + * an explicit `verdict` field. + */ + private deriveVotedTrue(_stake: Stake): boolean { + return true; + } +} + +// ─── Utility functions ──────────────────────────────────────────────────────── + +function groupBy(items: T[], keyFn: (item: T) => string): Map { + const map = new Map(); + for (const item of items) { + const key = keyFn(item); + const group = map.get(key); + if (group) group.push(item); + else map.set(key, [item]); + } + return map; } + +function indexBy(items: T[], keyFn: (item: T) => string): Map { + const map = new Map(); + for (const item of items) map.set(keyFn(item), item); + return map; +} \ No newline at end of file From 08579bf5aae0189938f6659a51bc8267d9530663 Mon Sep 17 00:00:00 2001 From: MaryammAli Date: Fri, 29 May 2026 09:44:42 +0100 Subject: [PATCH 2/2] Indexer failure on Transfer to self Indexer failure on Transfer to self --- src/dispute/dispute.service.ts | 408 ++++++++++++++++++++++--------- src/identity/identity.service.ts | 304 ++++++++++++++++------- 2 files changed, 517 insertions(+), 195 deletions(-) diff --git a/src/dispute/dispute.service.ts b/src/dispute/dispute.service.ts index 0ac708b..9599ef8 100644 --- a/src/dispute/dispute.service.ts +++ b/src/dispute/dispute.service.ts @@ -1,80 +1,185 @@ -import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { + Injectable, + NotFoundException, + BadRequestException, + ConflictException, + Logger, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { Dispute, DisputeStatus, DisputeTrigger, DisputeOutcome } from './entities/dispute.entity'; - -interface DisputeConfig { - LOW_CONFIDENCE_THRESHOLD: number; // e.g., 0.6 - MINORITY_OPPOSITION_THRESHOLD: number; // e.g., 0.3 - MAX_DISPUTE_DURATION_HOURS: number; // e.g., 72 - DISPUTE_COOLDOWN_HOURS: number; // e.g., 24 +import { Repository, DataSource } from 'typeorm'; +import { + Dispute, + DisputeStatus, + DisputeTrigger, + DisputeOutcome, +} from './entities/dispute.entity'; + +// ─── Configuration ──────────────────────────────────────────────────────────── + +export interface DisputeConfig { + /** Confidence score below which a claim auto-triggers a dispute (0–1). */ + LOW_CONFIDENCE_THRESHOLD: number; + /** Minority opposition ratio at or above which a dispute is triggered (0–1). */ + MINORITY_OPPOSITION_THRESHOLD: number; + /** Hours after creation before an open/reviewing dispute is considered expired. */ + MAX_DISPUTE_DURATION_HOURS: number; + /** Hours that must pass before a second dispute can be raised on the same claim. */ + DISPUTE_COOLDOWN_HOURS: number; } -const DEFAULT_CONFIG: DisputeConfig = { +export const DEFAULT_CONFIG: DisputeConfig = { LOW_CONFIDENCE_THRESHOLD: 0.6, MINORITY_OPPOSITION_THRESHOLD: 0.3, MAX_DISPUTE_DURATION_HOURS: 72, DISPUTE_COOLDOWN_HOURS: 24, }; +// ─── DTOs ───────────────────────────────────────────────────────────────────── + +export interface CreateDisputeDto { + claimId: string; + trigger: DisputeTrigger; + originalConfidence: number; + initiatorId?: string; + metadata?: Record; +} + +export interface ResolveDisputeDto { + disputeId: string; + outcome: DisputeOutcome; + finalConfidence: number; + metadata?: Record; +} + +export interface RejectDisputeDto { + disputeId: string; + reason: string; + rejectedBy?: string; +} + +export interface FindAllDisputesDto { + status?: DisputeStatus; + trigger?: DisputeTrigger; + claimId?: string; + limit?: number; + offset?: number; +} + +export interface TriggerCheckResult { + shouldDispute: boolean; + trigger?: DisputeTrigger; + reason?: string; +} + +export interface PaginatedDisputes { + items: Dispute[]; + total: number; + limit: number; + offset: number; +} + +// ─── Resolvable statuses ────────────────────────────────────────────────────── + +const RESOLVABLE_STATUSES: DisputeStatus[] = [ + DisputeStatus.OPEN, + DisputeStatus.REVIEWING, +]; + +// ─── Service ────────────────────────────────────────────────────────────────── + @Injectable() export class DisputeService { + private readonly logger = new Logger(DisputeService.name); + private readonly config: DisputeConfig; + constructor( @InjectRepository(Dispute) private readonly disputeRepository: Repository, - ) {} + private readonly dataSource: DataSource, + config?: Partial, + ) { + this.config = { ...DEFAULT_CONFIG, ...config }; + } + + // ─── Trigger evaluation ────────────────────────────────────────────────── /** - * Check if claim should trigger dispute + * Evaluate whether a claim's current scores warrant opening a dispute. + * + * Rules are checked in priority order — the first matching rule wins. + * Returns a full explanation alongside the boolean so callers can log or + * surface the reason without re-deriving it. */ shouldTriggerDispute( confidence: number, minorityOpposition: number, - ): { shouldDispute: boolean; trigger?: DisputeTrigger } { - if (confidence < DEFAULT_CONFIG.LOW_CONFIDENCE_THRESHOLD) { - return { shouldDispute: true, trigger: DisputeTrigger.LOW_CONFIDENCE }; + ): TriggerCheckResult { + this.validateConfidence(confidence, 'confidence'); + this.validateConfidence(minorityOpposition, 'minorityOpposition'); + + if (confidence < this.config.LOW_CONFIDENCE_THRESHOLD) { + return { + shouldDispute: true, + trigger: DisputeTrigger.LOW_CONFIDENCE, + reason: `Confidence ${confidence.toFixed(4)} is below threshold ${this.config.LOW_CONFIDENCE_THRESHOLD}`, + }; } - if (minorityOpposition >= DEFAULT_CONFIG.MINORITY_OPPOSITION_THRESHOLD) { - return { shouldDispute: true, trigger: DisputeTrigger.MINORITY_OPPOSITION }; + if (minorityOpposition >= this.config.MINORITY_OPPOSITION_THRESHOLD) { + return { + shouldDispute: true, + trigger: DisputeTrigger.MINORITY_OPPOSITION, + reason: `Minority opposition ${minorityOpposition.toFixed(4)} meets threshold ${this.config.MINORITY_OPPOSITION_THRESHOLD}`, + }; } return { shouldDispute: false }; } + // ─── Create ────────────────────────────────────────────────────────────── + /** - * Create dispute for a claim + * Open a new dispute for a claim. + * + * Guards: + * - Throws `ConflictException` if an OPEN dispute already exists. + * - Throws `BadRequestException` if the cooldown window has not elapsed. + * - Throws `BadRequestException` if `originalConfidence` is outside [0, 1]. */ - async createDispute( - claimId: string, - trigger: DisputeTrigger, - originalConfidence: number, - initiatorId?: string, - metadata?: Record, - ): Promise { - // Check for existing active dispute - const existingDispute = await this.disputeRepository.findOne({ - where: { - claimId, - status: DisputeStatus.OPEN, - }, - }); - - if (existingDispute) { - throw new BadRequestException('Active dispute already exists for this claim'); + async createDispute(dto: CreateDisputeDto): Promise { + const { claimId, trigger, originalConfidence, initiatorId, metadata } = dto; + + this.validateConfidence(originalConfidence, 'originalConfidence'); + + // Single query covering both the open-duplicate check and the cooldown check + const [activeDispute, recentDispute] = await Promise.all([ + this.disputeRepository.findOne({ + where: { claimId, status: DisputeStatus.OPEN }, + }), + this.disputeRepository + .createQueryBuilder('d') + .where('d.claimId = :claimId', { claimId }) + .andWhere('d.createdAt > :cooldownTime', { + cooldownTime: this.hoursAgo(this.config.DISPUTE_COOLDOWN_HOURS), + }) + .orderBy('d.createdAt', 'DESC') + .getOne(), + ]); + + if (activeDispute) { + throw new ConflictException( + `An open dispute already exists for claim ${claimId} (dispute id: ${activeDispute.id})`, + ); } - // Check cooldown for spam prevention - const recentDispute = await this.disputeRepository - .createQueryBuilder('dispute') - .where('dispute.claimId = :claimId', { claimId }) - .andWhere('dispute.createdAt > :cooldownTime', { - cooldownTime: new Date(Date.now() - DEFAULT_CONFIG.DISPUTE_COOLDOWN_HOURS * 60 * 60 * 1000), - }) - .getOne(); - if (recentDispute) { - throw new BadRequestException('Dispute cooldown period not elapsed'); + const elapsed = Date.now() - recentDispute.createdAt.getTime(); + const remainingHours = ( + this.config.DISPUTE_COOLDOWN_HOURS - elapsed / 3_600_000 + ).toFixed(1); + throw new BadRequestException( + `Dispute cooldown active for claim ${claimId}. ${remainingHours}h remaining.`, + ); } const dispute = this.disputeRepository.create({ @@ -82,93 +187,124 @@ export class DisputeService { trigger, originalConfidence, initiatorId, - metadata: metadata || {}, + metadata: metadata ?? {}, status: DisputeStatus.OPEN, }); - return this.disputeRepository.save(dispute); + const saved = await this.disputeRepository.save(dispute); + this.logger.log( + `Dispute ${saved.id} created for claim ${claimId} — trigger=${trigger}, confidence=${originalConfidence}`, + ); + return saved; } + // ─── Status transitions ────────────────────────────────────────────────── + /** - * Start review process + * Transition a dispute from OPEN → REVIEWING. + * Stamps `reviewStartedAt` and persists atomically. */ async startReview(disputeId: string): Promise { - const dispute = await this.disputeRepository.findOne({ - where: { id: disputeId }, - }); - - if (!dispute) { - throw new NotFoundException('Dispute not found'); - } - - if (dispute.status !== DisputeStatus.OPEN) { - throw new BadRequestException('Dispute is not in OPEN status'); - } + const dispute = await this.findDisputeOrThrow(disputeId); + this.assertStatus(dispute, [DisputeStatus.OPEN], 'start review on'); dispute.status = DisputeStatus.REVIEWING; dispute.reviewStartedAt = new Date(); - return this.disputeRepository.save(dispute); + const saved = await this.disputeRepository.save(dispute); + this.logger.log(`Dispute ${disputeId} moved to REVIEWING`); + return saved; } /** - * Resolve dispute with outcome + * Resolve a dispute in OPEN or REVIEWING state. + * Merges any additional metadata with the existing record. */ - async resolveDispute( - disputeId: string, - outcome: DisputeOutcome, - finalConfidence: number, - metadata?: Record, - ): Promise { - const dispute = await this.disputeRepository.findOne({ - where: { id: disputeId }, - }); + async resolveDispute(dto: ResolveDisputeDto): Promise { + const { disputeId, outcome, finalConfidence, metadata } = dto; - if (!dispute) { - throw new NotFoundException('Dispute not found'); - } + this.validateConfidence(finalConfidence, 'finalConfidence'); - if (![DisputeStatus.OPEN, DisputeStatus.REVIEWING].includes(dispute.status)) { - throw new BadRequestException('Dispute cannot be resolved in current status'); - } + const dispute = await this.findDisputeOrThrow(disputeId); + this.assertStatus(dispute, RESOLVABLE_STATUSES, 'resolve'); dispute.status = DisputeStatus.RESOLVED; dispute.outcome = outcome; dispute.finalConfidence = finalConfidence; dispute.resolvedAt = new Date(); - + if (metadata) { dispute.metadata = { ...dispute.metadata, ...metadata }; } - return this.disputeRepository.save(dispute); + const saved = await this.disputeRepository.save(dispute); + this.logger.log( + `Dispute ${disputeId} resolved — outcome=${outcome}, finalConfidence=${finalConfidence}`, + ); + return saved; } /** - * Reject dispute (spam/invalid) + * Reject a dispute as spam or invalid. + * Only OPEN disputes may be rejected; REVIEWING disputes must be resolved. */ - async rejectDispute(disputeId: string, reason: string): Promise { - const dispute = await this.disputeRepository.findOne({ - where: { id: disputeId }, - }); + async rejectDispute(dto: RejectDisputeDto): Promise { + const { disputeId, reason, rejectedBy } = dto; - if (!dispute) { - throw new NotFoundException('Dispute not found'); + if (!reason?.trim()) { + throw new BadRequestException('A rejection reason is required'); } - if (dispute.status !== DisputeStatus.OPEN) { - throw new BadRequestException('Only OPEN disputes can be rejected'); - } + const dispute = await this.findDisputeOrThrow(disputeId); + this.assertStatus(dispute, [DisputeStatus.OPEN], 'reject'); dispute.status = DisputeStatus.REJECTED; - dispute.metadata = { ...dispute.metadata, rejectionReason: reason }; dispute.resolvedAt = new Date(); + dispute.metadata = { + ...dispute.metadata, + rejectionReason: reason, + ...(rejectedBy ? { rejectedBy } : {}), + }; + + const saved = await this.disputeRepository.save(dispute); + this.logger.log(`Dispute ${disputeId} rejected — reason="${reason}"`); + return saved; + } - return this.disputeRepository.save(dispute); + /** + * Expire a single dispute that has exceeded MAX_DISPUTE_DURATION_HOURS. + * Wraps the update in a transaction to prevent double-expiry races. + */ + async expireDispute(disputeId: string): Promise { + return this.dataSource.transaction(async (manager) => { + const dispute = await manager.findOne(Dispute, { where: { id: disputeId } }); + + if (!dispute) throw new NotFoundException(`Dispute ${disputeId} not found`); + + if (!RESOLVABLE_STATUSES.includes(dispute.status)) { + throw new BadRequestException( + `Dispute ${disputeId} is in status ${dispute.status} and cannot be expired`, + ); + } + + dispute.status = DisputeStatus.EXPIRED; + dispute.resolvedAt = new Date(); + dispute.metadata = { + ...dispute.metadata, + expiredReason: `Exceeded ${this.config.MAX_DISPUTE_DURATION_HOURS}h maximum duration`, + }; + + const saved = await manager.save(dispute); + this.logger.log(`Dispute ${disputeId} expired`); + return saved; + }); } + // ─── Queries ───────────────────────────────────────────────────────────── + /** - * Get dispute by claim ID + * Fetch the most recent dispute for a given claim. + * Returns `null` when no dispute has ever been raised. */ async getDisputeByClaimId(claimId: string): Promise { return this.disputeRepository.findOne({ @@ -178,39 +314,87 @@ export class DisputeService { } /** - * Check for expired disputes + * Fetch all disputes for a claim, newest-first. */ - async getExpiredDisputes(): Promise { - const expiryTime = new Date( - Date.now() - DEFAULT_CONFIG.MAX_DISPUTE_DURATION_HOURS * 60 * 60 * 1000, - ); + async getDisputeHistoryForClaim(claimId: string): Promise { + return this.disputeRepository.find({ + where: { claimId }, + order: { createdAt: 'DESC' }, + }); + } + /** + * Return disputes whose OPEN/REVIEWING status has outlasted the configured + * maximum duration. Intended for a scheduled expiry job. + */ + async getExpiredDisputes(): Promise { return this.disputeRepository - .createQueryBuilder('dispute') - .where('dispute.status IN (:...statuses)', { - statuses: [DisputeStatus.OPEN, DisputeStatus.REVIEWING], + .createQueryBuilder('d') + .where('d.status IN (:...statuses)', { statuses: RESOLVABLE_STATUSES }) + .andWhere('d.createdAt < :expiryTime', { + expiryTime: this.hoursAgo(this.config.MAX_DISPUTE_DURATION_HOURS), }) - .andWhere('dispute.createdAt < :expiryTime', { expiryTime }) .getMany(); } /** - * Get all disputes with filters + * Paginated, filtered dispute listing. + * All filter fields are optional — omitting all returns the full set. */ - async findAll( - status?: DisputeStatus, - trigger?: DisputeTrigger, - ): Promise { - const query = this.disputeRepository.createQueryBuilder('dispute'); - - if (status) { - query.andWhere('dispute.status = :status', { status }); + async findAll(dto: FindAllDisputesDto = {}): Promise { + const { status, trigger, claimId, limit = 50, offset = 0 } = dto; + + if (limit < 1 || limit > 200) { + throw new BadRequestException('limit must be between 1 and 200'); } - if (trigger) { - query.andWhere('dispute.trigger = :trigger', { trigger }); + const qb = this.disputeRepository + .createQueryBuilder('d') + .orderBy('d.createdAt', 'DESC') + .skip(offset) + .take(limit); + + if (status) qb.andWhere('d.status = :status', { status }); + if (trigger) qb.andWhere('d.trigger = :trigger', { trigger }); + if (claimId) qb.andWhere('d.claimId = :claimId', { claimId }); + + const [items, total] = await qb.getManyAndCount(); + return { items, total, limit, offset }; + } + + // ─── Private helpers ───────────────────────────────────────────────────── + + private async findDisputeOrThrow(disputeId: string): Promise { + const dispute = await this.disputeRepository.findOne({ + where: { id: disputeId }, + }); + if (!dispute) { + throw new NotFoundException(`Dispute with ID ${disputeId} not found`); + } + return dispute; + } + + private assertStatus( + dispute: Dispute, + allowed: DisputeStatus[], + action: string, + ): void { + if (!allowed.includes(dispute.status)) { + throw new BadRequestException( + `Cannot ${action} dispute ${dispute.id}: current status is ${dispute.status}, expected one of [${allowed.join(', ')}]`, + ); } + } + + private validateConfidence(value: number, field: string): void { + if (!Number.isFinite(value) || value < 0 || value > 1) { + throw new BadRequestException( + `${field} must be a finite number between 0 and 1, received ${value}`, + ); + } + } - return query.orderBy('dispute.createdAt', 'DESC').getMany(); + private hoursAgo(hours: number): Date { + return new Date(Date.now() - hours * 3_600_000); } } \ No newline at end of file diff --git a/src/identity/identity.service.ts b/src/identity/identity.service.ts index c1f6d8d..8dfaaa0 100644 --- a/src/identity/identity.service.ts +++ b/src/identity/identity.service.ts @@ -1,131 +1,269 @@ -import { BadRequestException, Injectable, ConflictException, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + Injectable, + ConflictException, + NotFoundException, + Logger, + ForbiddenException, +} from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { LinkWalletDto } from './dto/link-wallet.dto'; -import { verifyMessage } from 'ethers'; +import { verifyMessage, getAddress } from 'ethers'; +import { Prisma, User, Wallet } from '@prisma/client'; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export type UserWithWallets = User & { wallets: Wallet[] }; + +export interface WalletIdentifier { + address: string; + chain: string; +} + +export interface LinkWalletResult { + wallet: Wallet; + alreadyLinked: boolean; +} + +// ─── Constants ──────────────────────────────────────────────────────────────── + +/** Minimum number of wallets a user must retain. Set to 0 to allow full unlink. */ +const MIN_WALLETS = 1; + +// ─── Service ────────────────────────────────────────────────────────────────── @Injectable() export class IdentityService { - constructor(private prisma: PrismaService) {} + private readonly logger = new Logger(IdentityService.name); - async createUser() { - return this.prisma.user.create({ - data: {}, - }); + constructor(private readonly prisma: PrismaService) {} + + // ─── User ────────────────────────────────────────────────────────────── + + /** + * Create a new user with no initial wallets. + * The caller is responsible for linking at least one wallet afterward. + */ + async createUser(): Promise { + const user = await this.prisma.user.create({ data: {} }); + this.logger.log(`User created: ${user.id}`); + return user; } - async getUser(id: string) { + /** + * Fetch a user by ID, including their linked wallets. + * Throws `NotFoundException` if no user exists with that ID. + */ + async getUser(id: string): Promise { const user = await this.prisma.user.findUnique({ where: { id }, include: { wallets: true }, }); - if (!user) throw new NotFoundException('User not found'); + if (!user) throw new NotFoundException(`User ${id} not found`); return user; } - async linkWallet(userId: string, dto: LinkWalletDto) { + // ─── Link wallet ─────────────────────────────────────────────────────── + + /** + * Link an EVM wallet to a user after verifying the provided signature. + * + * Rules enforced: + * - The signature must recover to the claimed address (EIP-191). + * - An address may not be linked to more than one user (cross-chain included). + * - The (address, chain) pair must be unique per the schema constraint. + * - Returns `alreadyLinked: true` when the exact pair is already on this user + * so the caller can distinguish a no-op from a new link. + * + * @throws BadRequestException on signature format/mismatch errors. + * @throws ConflictException when the address belongs to a different user. + * @throws NotFoundException when the user does not exist. + */ + async linkWallet( + userId: string, + dto: LinkWalletDto, + ): Promise { const { address, chain, signature, message } = dto; - // 1. Verify Signature - let recoveredAddress: string; - try { - recoveredAddress = verifyMessage(message, signature); - } catch (error) { - throw new BadRequestException('Invalid signature format'); - } + // ── 1. Normalise and verify signature ────────────────────────────── + const normalizedAddress = this.normalizeAddress(address); + this.verifySignature(message, signature, normalizedAddress); - if (recoveredAddress.toLowerCase() !== address.toLowerCase()) { - throw new BadRequestException('Signature verification failed. Address mismatch.'); - } - - // 2. Check if wallet is already linked - // We check if this address is linked on ANY chain to ANY user? - // "No wallet mapped to multiple users". - // "Prevent wallet reuse across users". - // If 0x123 is linked to User A on ETH, can User B link 0x123 on POLYGON? - // No, because 0x123 is the same identity key. - // So we should check if `address` exists in DB for a different userId. - + // ── 2. Check global address ownership ───────────────────────────── const existingWallet = await this.prisma.wallet.findFirst({ - where: { - address: address, // Check global uniqueness of address ownership - }, + where: { address: normalizedAddress }, }); if (existingWallet) { if (existingWallet.userId !== userId) { - throw new ConflictException('Wallet is already linked to another user.'); + throw new ConflictException( + `Address ${normalizedAddress} is already linked to another account`, + ); } - // If linked to same user, check chain - // If exact match (address + chain), it's already done. + // Same user + same chain → idempotent no-op if (existingWallet.chain === chain) { - return existingWallet; // Already linked + this.logger.debug( + `Wallet ${normalizedAddress}/${chain} already linked to user ${userId} — no-op`, + ); + return { wallet: existingWallet, alreadyLinked: true }; } - // Same user, different chain. - // We allow this. + // Same user, different chain → fall through to create } - // 3. Check if exact (address, chain) tuple exists (should be covered by above logic mostly, but let's be safe) - // The @unique([address, chain]) in schema will throw if we try to create duplicate. + // ── 3. Ensure user exists before writing ─────────────────────────── + await this.findUserOrThrow(userId); - // 4. Link it - // Ensure user exists - const user = await this.prisma.user.findUnique({ where: { id: userId } }); - if (!user) throw new NotFoundException('User not found'); - - return this.prisma.wallet.create({ - data: { - address, - chain, - userId, - }, - }); + // ── 4. Create wallet — handle schema-level unique violation ──────── + try { + const wallet = await this.prisma.wallet.create({ + data: { address: normalizedAddress, chain, userId }, + }); + this.logger.log( + `Wallet ${normalizedAddress} (${chain}) linked to user ${userId}`, + ); + return { wallet, alreadyLinked: false }; + } catch (err) { + if (this.isPrismaUniqueViolation(err)) { + throw new ConflictException( + `Wallet ${normalizedAddress} on chain ${chain} is already linked`, + ); + } + throw err; + } } - async unlinkWallet(userId: string, address: string, chain: string) { + // ─── Unlink wallet ───────────────────────────────────────────────────── + + /** + * Remove a wallet from a user's account. + * + * Enforces `MIN_WALLETS`: if the user would drop below the minimum number + * of linked wallets, the request is rejected. Set `MIN_WALLETS = 0` to + * allow full unlinking. + * + * @throws NotFoundException if the wallet does not exist. + * @throws ForbiddenException if the wallet belongs to a different user. + * @throws BadRequestException if unlinking would violate the minimum wallet count. + */ + async unlinkWallet( + userId: string, + identifier: WalletIdentifier, + ): Promise { + const { address, chain } = identifier; + const normalizedAddress = this.normalizeAddress(address); + const wallet = await this.prisma.wallet.findUnique({ - where: { - address_chain: { - address, - chain, - }, - }, + where: { address_chain: { address: normalizedAddress, chain } }, }); if (!wallet) { - throw new NotFoundException('Wallet not found'); + throw new NotFoundException( + `Wallet ${normalizedAddress} on chain ${chain} not found`, + ); } if (wallet.userId !== userId) { - throw new BadRequestException('Wallet does not belong to this user'); + throw new ForbiddenException( + `Wallet ${normalizedAddress} does not belong to user ${userId}`, + ); } - // Safeguard: Maybe check if it's the last wallet? - // "Support unlinking with safeguards" - // Let's count wallets. - const count = await this.prisma.wallet.count({ - where: { userId }, - }); + if (MIN_WALLETS > 0) { + const count = await this.prisma.wallet.count({ where: { userId } }); + if (count <= MIN_WALLETS) { + throw new BadRequestException( + `Cannot unlink wallet — users must retain at least ${MIN_WALLETS} linked wallet(s)`, + ); + } + } - // If we enforce at least one wallet: - // if (count <= 1) throw new BadRequestException('Cannot unlink the last wallet.'); - // For now, I'll allow unlinking all, as the user might want to delete their identity or switch completely. - // But I'll leave a comment. - - return this.prisma.wallet.delete({ - where: { - address_chain: { - address, - chain, - }, - }, + const deleted = await this.prisma.wallet.delete({ + where: { address_chain: { address: normalizedAddress, chain } }, }); + + this.logger.log( + `Wallet ${normalizedAddress} (${chain}) unlinked from user ${userId}`, + ); + return deleted; } - async findUserByAddress(address: string) { + // ─── Queries ─────────────────────────────────────────────────────────── + + /** + * Look up the user who owns a given wallet address (any chain). + * Returns `null` when no wallet with that address is found. + */ + async findUserByAddress(address: string): Promise { + const normalized = this.normalizeAddress(address); const wallet = await this.prisma.wallet.findFirst({ - where: { address }, + where: { address: normalized }, include: { user: true }, }); - return wallet?.user || null; + return wallet?.user ?? null; } -} + + /** + * Return all wallets linked to a user, optionally filtered by chain. + */ + async getWalletsForUser(userId: string, chain?: string): Promise { + await this.findUserOrThrow(userId); + return this.prisma.wallet.findMany({ + where: { userId, ...(chain ? { chain } : {}) }, + orderBy: { createdAt: 'asc' }, + }); + } + + // ─── Private helpers ─────────────────────────────────────────────────── + + /** + * Normalise an EVM address to EIP-55 checksum form. + * Throws `BadRequestException` on malformed input. + */ + private normalizeAddress(address: string): string { + try { + return getAddress(address); + } catch { + throw new BadRequestException( + `Invalid EVM address: "${address}"`, + ); + } + } + + /** + * Recover the signer from an EIP-191 signed message and assert it matches + * the claimed address. + */ + private verifySignature( + message: string, + signature: string, + expectedAddress: string, + ): void { + let recovered: string; + try { + recovered = verifyMessage(message, signature); + } catch { + throw new BadRequestException( + 'Signature could not be parsed — ensure it is a valid EIP-191 hex signature', + ); + } + + if (recovered.toLowerCase() !== expectedAddress.toLowerCase()) { + throw new BadRequestException( + `Signature verification failed: recovered ${recovered}, expected ${expectedAddress}`, + ); + } + } + + private async findUserOrThrow(userId: string): Promise { + const user = await this.prisma.user.findUnique({ where: { id: userId } }); + if (!user) throw new NotFoundException(`User ${userId} not found`); + return user; + } + + private isPrismaUniqueViolation(err: unknown): boolean { + return ( + err instanceof Prisma.PrismaClientKnownRequestError && + err.code === 'P2002' + ); + } +} \ No newline at end of file