diff --git a/docs/public-transparency-contract.md b/docs/public-transparency-contract.md new file mode 100644 index 0000000..434aca7 --- /dev/null +++ b/docs/public-transparency-contract.md @@ -0,0 +1,30 @@ +# Public transparency contract + +The public protocol transparency endpoint is intentionally designed as a stable contract for community dashboards and external reporting. + +## Endpoint + +- GET /api/v1/stats/public + +## Stability policy + +The contract is versioned by path and field set rather than by silent mutation. In practice, this means: + +- additive fields are allowed without a version bump; +- renaming or removing existing fields requires a new versioned path or a deliberate contract bump; +- breaking response-shape changes should be treated as a new public contract version, not as an in-place change to the existing contract. + +This policy keeps community dashboards predictable while still allowing the backend to evolve. The public endpoint should therefore be treated as a semver-style public API surface: the contract is stable, documented, and intentionally conservative. + +## Current payload + +The endpoint returns: + +- totalIntents +- openIntents +- filledIntents +- totalVolume +- activeSolverCount +- wsSubscriberCount +- perChain summary +- contract name and schema version metadata diff --git a/src/common/stellar-signature.ts b/src/common/stellar-signature.ts index c1313e8..f02e5e1 100644 --- a/src/common/stellar-signature.ts +++ b/src/common/stellar-signature.ts @@ -73,3 +73,10 @@ export function buildRegisterMessage(address: string): string { export function buildSolverStatusMessage(action: "deactivate" | "reactivate" | "deregister", address: string): string { return `${action}:${address}`; } + +/** + * Build the canonical message that a solver must sign to submit a slash dispute. + */ +export function buildDisputeMessage(slashId: string, address: string, reason: string): string { + return `dispute:${slashId}:${address}:${reason}`; +} diff --git a/src/intents/intents-sweeper.service.ts b/src/intents/intents-sweeper.service.ts index f2ec0cd..334ba0a 100644 --- a/src/intents/intents-sweeper.service.ts +++ b/src/intents/intents-sweeper.service.ts @@ -90,6 +90,7 @@ export class IntentsSweeperService implements OnModuleInit, OnModuleDestroy { } await this.solversService.recordFailedFill(solver); + const slashRecord = await this.solversService.recordSlash(solver, intentId, reason, now); const result = await this.solverRegistryService.slashSolver({ solverAddress: solver, @@ -97,7 +98,7 @@ export class IntentsSweeperService implements OnModuleInit, OnModuleDestroy { reason, }); console.log( - `[sweeper] slashed solver=${solver} for intent=${intentId}: ${result.detail}`, + `[sweeper] slashed solver=${solver} for intent=${intentId}: ${result.detail} slashId=${slashRecord?.slashId ?? "unknown"}`, ); } } diff --git a/src/intents/intents.module.ts b/src/intents/intents.module.ts index 2c14769..db5120d 100644 --- a/src/intents/intents.module.ts +++ b/src/intents/intents.module.ts @@ -1,4 +1,4 @@ -import { Module } from "@nestjs/common"; +import { Module, forwardRef } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { IntentsService } from "./intents.service"; import { IntentsController } from "./intents.controller"; @@ -15,7 +15,7 @@ import { AppConfig } from "../config/configuration"; import { PrismaService } from "../prisma/prisma.service"; @Module({ - imports: [SolversModule, RoutingModule, TokensModule, SorobanModule], + imports: [forwardRef(() => SolversModule), RoutingModule, TokensModule, SorobanModule], controllers: [IntentsController], providers: [ // Select the persistence adapter based on INTENTS_PERSISTENCE env var. diff --git a/src/solvers/solvers.controller.ts b/src/solvers/solvers.controller.ts index 7292ba1..679bbff 100644 --- a/src/solvers/solvers.controller.ts +++ b/src/solvers/solvers.controller.ts @@ -1,21 +1,33 @@ import { + BadRequestException, Body, Controller, Get, NotFoundException, Param, Post, + Query, } from "@nestjs/common"; -import { ApiTags } from "@nestjs/swagger"; -import { SolversService } from "./solvers.service"; +import { ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger"; +import { IntentsService } from "../intents/intents.service"; +import { buildDisputeMessage, verifyStellarSignature, buildSolverStatusMessage } from "../common/stellar-signature"; +import { SolversService, LeaderboardWindow } from "./solvers.service"; import { RegisterSolverDto } from "./dto/register-solver.dto"; import { UpdateSolverStatusDto } from "./dto/update-solver-status.dto"; -import { verifyStellarSignature, buildSolverStatusMessage } from "../common/stellar-signature"; + +const WINDOW_SECONDS: Record, number> = { + "24h": 24 * 60 * 60, + "7d": 7 * 24 * 60 * 60, + "30d": 30 * 24 * 60 * 60, +}; @ApiTags("solvers") @Controller("api/v1/solvers") export class SolversController { - constructor(private readonly solversService: SolversService) {} + constructor( + private readonly solversService: SolversService, + private readonly intentsService: IntentsService, + ) {} @Post() async register(@Body() dto: RegisterSolverDto) { @@ -30,8 +42,74 @@ export class SolversController { }); } + @Get("leaderboard") + @ApiOperation({ + summary: "Windowed solver leaderboard", + description: + "Returns the ranked solver list for a specific window. This endpoint is intended for recent-performance visibility and does not alter the legacy all-time leaderboard.", + }) + @ApiQuery({ name: "window", required: false, enum: ["24h", "7d", "30d", "all"], description: "Time window over which to compute rankings." }) + async getLeaderboard(@Query("window") window: string = "all") { + const resolvedWindow = this.normalizeWindow(window); + const solvers = await this.solversService.getAll(); + const intents = await this.intentsService.getAll(); + const now = Math.floor(Date.now() / 1000); + const cutoff = resolvedWindow === "all" ? 0 : now - WINDOW_SECONDS[resolvedWindow]; + + const ranked = solvers + .map((solver) => { + const recentIntents = intents.filter((intent) => { + if (intent.solver !== solver.address || intent.state !== "filled") return false; + const timestamp = intent.filledAt ?? intent.createdAt; + return resolvedWindow === "all" || timestamp >= cutoff; + }); + + const slashedRecent = intents.filter((intent) => { + if (intent.solver !== solver.address || intent.state !== "slashed") return false; + const timestamp = intent.slashedAt ?? intent.createdAt; + return resolvedWindow === "all" || timestamp >= cutoff; + }); + + const fillsCompleted = recentIntents.length; + const fillsFailed = slashedRecent.length; + const total = fillsCompleted + fillsFailed; + const successRate = total > 0 ? fillsCompleted / total : 0; + const ageDays = Math.max(0, (now - solver.registeredAt) / 86400); + const reputationScore = Number( + (successRate * Math.exp(-ageDays / 180)).toFixed(4), + ); + + return { + address: solver.address, + name: solver.name, + fillsCompleted, + fillsFailed, + successRate: Number(successRate.toFixed(4)), + reputationScore, + totalVolume: recentIntents + .reduce((sum, intent) => sum + BigInt(intent.fillAmount ?? "0"), 0n) + .toString(), + avgFillTime: recentIntents.length + ? Math.round( + recentIntents.reduce((sum, intent) => { + if (!intent.filledAt) return sum; + return sum + (intent.filledAt - intent.createdAt); + }, 0) / recentIntents.length, + ) + : 0, + bondAmount: solver.bondAmount, + isActive: solver.isActive, + window: resolvedWindow, + }; + }) + .filter((entry) => entry.fillsCompleted > 0 || entry.fillsFailed > 0 || resolvedWindow === "all") + .sort((a, b) => b.reputationScore - a.reputationScore || b.fillsCompleted - a.fillsCompleted); + + return { solvers: ranked, count: ranked.length, window: resolvedWindow }; + } + @Get() - async getLeaderboard() { + async getLegacyLeaderboard() { const solvers = (await this.solversService.getAll()).sort( (a, b) => b.fillsCompleted - a.fillsCompleted, ); @@ -46,30 +124,107 @@ export class SolversController { } @Get(":address/stats") - async getSolverStats(@Param("address") address: string) { + async getSolverStats(@Param("address") address: string, @Query("window") window?: string) { const solver = await this.solversService.get(address); if (!solver) throw new NotFoundException("Solver not found"); - const total = solver.fillsCompleted + solver.fillsFailed; - const successRate = total > 0 ? solver.fillsCompleted / total : 0; - const ageDays = Math.max(0, (Date.now() / 1000 - solver.registeredAt) / 86400); - const reputationScore = parseFloat( - (successRate * Math.exp(-ageDays / 180)).toFixed(4), - ); + const resolvedWindow = this.normalizeWindow(window ?? "all"); + const intents = await this.intentsService.getAll(); + const now = Math.floor(Date.now() / 1000); + const cutoff = resolvedWindow === "all" ? 0 : now - WINDOW_SECONDS[resolvedWindow]; + + const recentIntents = intents.filter((intent) => { + if (intent.solver !== address) return false; + const timestamp = intent.state === "filled" ? intent.filledAt ?? intent.createdAt : intent.slashedAt ?? intent.createdAt; + return resolvedWindow === "all" || timestamp >= cutoff; + }); + + const fillsCompleted = recentIntents.filter((intent) => intent.state === "filled").length; + const fillsFailed = recentIntents.filter((intent) => intent.state === "slashed").length; + const total = fillsCompleted + fillsFailed; + const successRate = total > 0 ? fillsCompleted / total : 0; + const ageDays = Math.max(0, (now - solver.registeredAt) / 86400); + const reputationScore = Number((successRate * Math.exp(-ageDays / 180)).toFixed(4)); return { address: solver.address, name: solver.name, - fillsCompleted: solver.fillsCompleted, - fillsFailed: solver.fillsFailed, - successRate: parseFloat(successRate.toFixed(4)), + fillsCompleted, + fillsFailed, + successRate: Number(successRate.toFixed(4)), reputationScore, - totalVolume: solver.totalVolume, - avgFillTime: solver.avgFillTime, + totalVolume: recentIntents + .filter((intent) => intent.state === "filled") + .reduce((sum, intent) => sum + BigInt(intent.fillAmount ?? "0"), 0n) + .toString(), + avgFillTime: recentIntents.filter((intent) => intent.state === "filled" && intent.filledAt != null).length + ? Math.round( + recentIntents + .filter((intent) => intent.state === "filled" && intent.filledAt != null) + .reduce((sum, intent) => sum + (intent.filledAt! - intent.createdAt), 0) / + recentIntents.filter((intent) => intent.state === "filled" && intent.filledAt != null).length, + ) + : 0, bondAmount: solver.bondAmount, + window: resolvedWindow, }; } + @Get(":address/slashes") + async getSlashHistory(@Param("address") address: string, @Query("page") page = "1", @Query("pageSize") pageSize = "25") { + const solver = await this.solversService.get(address); + if (!solver) throw new NotFoundException("Solver not found"); + + const pageNumber = Number(page) || 1; + const pageSizeNumber = Number(pageSize) || 25; + return this.solversService.getSlashHistory(address, pageNumber, pageSizeNumber); + } + + @Post(":address/slashes/:slashId/dispute") + async submitDispute( + @Param("address") address: string, + @Param("slashId") slashId: string, + @Body() dto: { reason: string; evidenceReference?: string; signature: string }, + ) { + const solver = await this.solversService.get(address); + if (!solver) throw new NotFoundException("Solver not found"); + + verifyStellarSignature( + address, + buildDisputeMessage(slashId, address, dto.reason), + dto.signature, + ); + + const record = await this.solversService.submitDispute( + address, + slashId, + dto.reason, + dto.evidenceReference, + ); + if (!record) throw new NotFoundException("Slash record not found"); + return record; + } + + @Post(":address/slashes/:slashId/dispute/resolve") + async resolveDispute( + @Param("address") address: string, + @Param("slashId") slashId: string, + @Body() dto: { resolution: "resolved-upheld" | "resolved-reversed"; reviewer?: string; note?: string }, + ) { + const solver = await this.solversService.get(address); + if (!solver) throw new NotFoundException("Solver not found"); + + const record = await this.solversService.resolveDispute( + address, + slashId, + dto.resolution, + dto.reviewer, + dto.note, + ); + if (!record) throw new NotFoundException("Slash record not found"); + return record; + } + @Post(":address/deregister") async deregisterSolver(@Param("address") address: string) { const solver = await this.solversService.deregister(address); @@ -94,4 +249,12 @@ export class SolversController { if (!solver) throw new NotFoundException("Solver not found"); return solver; } + + private normalizeWindow(window?: string): LeaderboardWindow { + const normalized = (window ?? "all").toLowerCase(); + if (normalized === "all" || normalized === "24h" || normalized === "7d" || normalized === "30d") { + return normalized as LeaderboardWindow; + } + throw new BadRequestException("Unsupported leaderboard window. Choose 24h, 7d, 30d, or all."); + } } diff --git a/src/solvers/solvers.module.ts b/src/solvers/solvers.module.ts index c526b0a..1090fb3 100644 --- a/src/solvers/solvers.module.ts +++ b/src/solvers/solvers.module.ts @@ -1,12 +1,14 @@ -import { Module } from "@nestjs/common"; +import { Module, forwardRef } from "@nestjs/common"; import { SolversController } from "./solvers.controller"; import { SolversService } from "./solvers.service"; import { SOLVERS_REPOSITORY } from "./solvers.repository"; import { InMemorySolversRepository } from "./in-memory-solvers.repository"; import { PrismaSolversRepository } from "./prisma-solvers.repository"; import { PrismaService } from "../prisma/prisma.service"; +import { IntentsModule } from "../intents/intents.module"; @Module({ + imports: [forwardRef(() => IntentsModule)], controllers: [SolversController], providers: [ // Select the persistence adapter based on SOLVERS_PERSISTENCE env var. diff --git a/src/solvers/solvers.service.ts b/src/solvers/solvers.service.ts index 4c40181..ce1201e 100644 --- a/src/solvers/solvers.service.ts +++ b/src/solvers/solvers.service.ts @@ -2,6 +2,24 @@ import { Inject, Injectable } from "@nestjs/common"; import { SOLVERS_REPOSITORY, ISolversRepository } from "./solvers.repository"; import { SolverRecord } from "./solvers.types"; +export type LeaderboardWindow = "24h" | "7d" | "30d" | "all"; + +export interface SlashDisputeRecord { + submittedAt: number; + reason: string; + evidenceReference?: string; +} + +export interface SlashRecord { + slashId: string; + solver: string; + intentId: string; + reason: string; + timestamp: number; + disputeStatus: "none" | "disputed" | "resolved-upheld" | "resolved-reversed"; + dispute?: SlashDisputeRecord; +} + /** * Orchestration layer for solver records. * @@ -12,6 +30,9 @@ import { SolverRecord } from "./solvers.types"; */ @Injectable() export class SolversService { + private readonly slashHistory = new Map(); + private slashSequence = 0; + constructor( @Inject(SOLVERS_REPOSITORY) private readonly repo: ISolversRepository, @@ -72,7 +93,7 @@ export class SolversService { async reactivate(address: string): Promise { const solver = await this.repo.findByAddress(address); if (!solver) return null; - const updated = { ...solver, isActive }; + const updated = { ...solver, isActive: true }; return this.repo.save(updated); } @@ -89,4 +110,89 @@ export class SolversService { const updated = { ...solver, fillsFailed: solver.fillsFailed + 1 }; return this.repo.save(updated); } + + async recordSlash( + solverAddress: string, + intentId: string, + reason: string, + timestamp: number, + ): Promise { + const solver = await this.repo.findByAddress(solverAddress); + if (!solver) return null; + + const record: SlashRecord = { + slashId: `slash-${++this.slashSequence}`, + solver: solverAddress, + intentId, + reason, + timestamp, + disputeStatus: "none", + }; + + const existing = this.slashHistory.get(solverAddress) ?? []; + existing.push(record); + this.slashHistory.set(solverAddress, existing); + return record; + } + + async getSlashHistory( + address: string, + page = 1, + pageSize = 25, + ): Promise<{ records: SlashRecord[]; page: number; pageSize: number; total: number }> { + const records = this.slashHistory.get(address) ?? []; + const sorted = [...records].sort((a, b) => b.timestamp - a.timestamp); + const start = (page - 1) * pageSize; + const pageRecords = sorted.slice(start, start + pageSize); + + return { + records: pageRecords, + page, + pageSize, + total: sorted.length, + }; + } + + async submitDispute( + address: string, + slashId: string, + reason: string, + evidenceReference?: string, + ): Promise { + const records = this.slashHistory.get(address) ?? []; + const record = records.find((entry) => entry.slashId === slashId); + if (!record) return null; + + record.disputeStatus = "disputed"; + record.dispute = { + submittedAt: Math.floor(Date.now() / 1000), + reason, + evidenceReference, + }; + + return record; + } + + async resolveDispute( + address: string, + slashId: string, + resolution: "resolved-upheld" | "resolved-reversed", + reviewer?: string, + note?: string, + ): Promise { + const records = this.slashHistory.get(address) ?? []; + const record = records.find((entry) => entry.slashId === slashId); + if (!record) return null; + + record.disputeStatus = resolution; + if (!record.dispute) { + record.dispute = { + submittedAt: Math.floor(Date.now() / 1000), + reason: note ?? "manual review", + evidenceReference: reviewer ? `reviewer:${reviewer}` : undefined, + }; + } + + return record; + } } diff --git a/src/stats/stats.controller.ts b/src/stats/stats.controller.ts index 62f95a9..5704d48 100644 --- a/src/stats/stats.controller.ts +++ b/src/stats/stats.controller.ts @@ -1,5 +1,5 @@ import { Controller, Get } from "@nestjs/common"; -import { ApiTags } from "@nestjs/swagger"; +import { ApiOperation, ApiTags } from "@nestjs/swagger"; import { StatsService } from "./stats.service"; @ApiTags("stats") @@ -12,6 +12,16 @@ export class StatsController { return this.statsService.getProtocolStats(); } + @Get("public") + @ApiOperation({ + summary: "Public protocol transparency snapshot", + description: + "Stable, versioned public metadata for external dashboards. Contract is intended to be additive-only across minor changes; any breaking field removal or shape change should require a version bump to a new contract path.", + }) + getPublicStats() { + return this.statsService.getPublicStats(); + } + @Get("ws") getWsStats() { return this.statsService.getWsStats(); diff --git a/src/stats/stats.service.ts b/src/stats/stats.service.ts index d6b4ad2..d0a283b 100644 --- a/src/stats/stats.service.ts +++ b/src/stats/stats.service.ts @@ -1,5 +1,6 @@ import { Injectable } from "@nestjs/common"; import { IntentsService } from "../intents/intents.service"; +import { SUPPORTED_CHAINS } from "../intents/intents.types"; import { SolversService } from "../solvers/solvers.service"; import { IntentsGateway } from "../intents/intents.gateway"; @@ -41,6 +42,47 @@ export class StatsService { }; } + async getPublicStats() { + const intents = await this.intentsService.getAll(); + const solvers = await this.solversService.getAll(); + const totalVolume = intents + .filter((intent) => intent.state === "filled") + .reduce((sum, intent) => sum + BigInt(intent.fillAmount ?? "0"), 0n); + + const perChain = Object.fromEntries( + SUPPORTED_CHAINS.map((chain) => { + const chainIntents = intents.filter((intent) => intent.srcChain === chain); + const filledIntents = chainIntents.filter((intent) => intent.state === "filled"); + const chainVolume = filledIntents.reduce( + (sum, intent) => sum + BigInt(intent.fillAmount ?? "0"), + 0n, + ); + + return [ + chain, + { + intentCount: chainIntents.length, + filledIntentCount: filledIntents.length, + totalVolume: chainVolume.toString(), + }, + ]; + }), + ); + + return { + contract: "protocol-transparency-v1", + schemaVersion: "1.0", + generatedAt: Math.floor(Date.now() / 1000), + totalIntents: intents.length, + openIntents: intents.filter((intent) => intent.state === "open").length, + filledIntents: intents.filter((intent) => intent.state === "filled").length, + totalVolume: totalVolume.toString(), + activeSolverCount: solvers.filter((solver) => solver.isActive).length, + wsSubscriberCount: this.intentsGateway.getSubscriberCount(), + perChain, + }; + } + getWsStats() { return { subscriberCount: this.intentsGateway.getSubscriberCount(),