Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions docs/public-transparency-contract.md
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions src/common/stellar-signature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
}
3 changes: 2 additions & 1 deletion src/intents/intents-sweeper.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,14 +90,15 @@ 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,
intentId,
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"}`,
);
}
}
4 changes: 2 additions & 2 deletions src/intents/intents.module.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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.
Expand Down
197 changes: 180 additions & 17 deletions src/solvers/solvers.controller.ts
Original file line number Diff line number Diff line change
@@ -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<Exclude<LeaderboardWindow, "all">, 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) {
Expand All @@ -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,
);
Expand All @@ -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);
Expand All @@ -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.");
}
}
4 changes: 3 additions & 1 deletion src/solvers/solvers.module.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
Loading