diff --git a/README.md b/README.md index 2583a367..285ee37e 100644 --- a/README.md +++ b/README.md @@ -43,12 +43,28 @@ GET /api/v1/stats — protocol stats GET /health — service health WS /ws — real-time intent feed GET /docs — Swagger / OpenAPI docs +GET /docs/v1 — OpenAPI UI for API v1 +GET /docs/v2 — OpenAPI UI for API v2 GET /api/v1/chain/health — Soroban RPC health (read-only) GET /api/v1/chain/ledger — latest Soroban ledger GET /api/v1/chain/network — Soroban network info GET /api/v1/chain/account/:key — Stellar account lookup ``` +### API version lifecycle + +HTTP API versions use Nest URI versioning and retain the `/api/vN/...` URL +shape. `/docs/v1-json` and `/docs/v2-json` serve version-specific OpenAPI +documents; the legacy `/docs-json` remains available as the combined document. +New controller versions are selected with Nest's controller `version` +metadata. + +Deprecation headers are enabled per version by setting `API_V1_DEPRECATED_AT` +to an ISO-8601 date. `API_V1_SUNSET_AT` adds the corresponding `Sunset` header, +and `API_V1_DEPRECATION_LINK` adds a `Link` header with `rel="deprecation"`. +These headers are omitted until configured. HTTP request counters and latency +histograms include a `version` label (`v1`, `v2`, or `unversioned`). + --- ## Local Development diff --git a/package.json b/package.json index b212542d..9e356c56 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,15 @@ "viem": "^2.44.4", "winston": "^3.13.0", "ws": "^8.18.0", - "joi": "^18.2.9", + "zod": "^4.6.5", + "@msgpack/msgpack": "^3.0.0", + "joi": "^18.2.9" + }, + "devDependencies": { + "@commitlint/cli": "^21.2.3", + "@commitlint/config-conventional": "^21.2.3", + "@nestjs/cli": "^11.0.24", + "@nestjs/schematics": "^11.1.0", "@nestjs/testing": "^12.1.0", "@stryker-mutator/core": "^8.0.0", "@stryker-mutator/jest-runner": "^8.0.0", diff --git a/src/abuse/abuse.controller.ts b/src/abuse/abuse.controller.ts index 4d82ac4e..0fd0b79e 100644 --- a/src/abuse/abuse.controller.ts +++ b/src/abuse/abuse.controller.ts @@ -25,7 +25,7 @@ import { AbuseScoreService } from "./abuse-score.service"; import { AbuseAuditEvent } from "./abuse.types"; @ApiTags("abuse") -@Controller("api/v1/abuse") +@Controller({ path: "abuse", version: "1" }) export class AbuseController { constructor(private readonly scorer: AbuseScoreService) {} diff --git a/src/analytics/analytics.controller.ts b/src/analytics/analytics.controller.ts index e0120cae..7867de45 100644 --- a/src/analytics/analytics.controller.ts +++ b/src/analytics/analytics.controller.ts @@ -5,7 +5,7 @@ import { ANALYTICS_METRICS, AnalyticsMetric, AnalyticsQuery } from "./analytics. import { AnalyticsQueryDto } from "./dto/analytics-query.dto"; @ApiTags("analytics") -@Controller("api/v1/analytics") +@Controller({ path: "analytics", version: "1" }) export class AnalyticsController { constructor(private readonly analyticsService: AnalyticsService) {} diff --git a/src/auth/api-keys/api-key.controller.ts b/src/auth/api-keys/api-key.controller.ts index 9f381f22..6f898c44 100644 --- a/src/auth/api-keys/api-key.controller.ts +++ b/src/auth/api-keys/api-key.controller.ts @@ -28,7 +28,7 @@ import { RotateApiKeyDto } from "./dto/rotate-api-key.dto"; * creation/rotation and is never persisted or logged. */ @ApiTags("admin") -@Controller("api/v1/admin/api-keys") +@Controller({ path: "admin/api-keys", version: "1" }) @UseGuards(AdminGuard) @RequireAdminRole("admin") export class ApiKeyController { diff --git a/src/auth/solver-credentials/solver-credential.controller.ts b/src/auth/solver-credentials/solver-credential.controller.ts index d9239ee0..c6302157 100644 --- a/src/auth/solver-credentials/solver-credential.controller.ts +++ b/src/auth/solver-credentials/solver-credential.controller.ts @@ -28,7 +28,7 @@ import { SolverJwtGuard } from "./solver-jwt.guard"; * Credentials can only be minted for the authenticated solver. */ @ApiTags("solvers") -@Controller("api/v1/solvers/:address/credentials") +@Controller({ path: "solvers/:address/credentials", version: "1" }) @UseGuards(SolverJwtGuard) export class SolverCredentialController { constructor(private readonly credentials: SolverCredentialService) {} diff --git a/src/common/api-versioning.spec.ts b/src/common/api-versioning.spec.ts new file mode 100644 index 00000000..ee2348d3 --- /dev/null +++ b/src/common/api-versioning.spec.ts @@ -0,0 +1,118 @@ +import { Controller, Get, INestApplication, Module } from "@nestjs/common"; +import { NestFactory } from "@nestjs/core"; +import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger"; +import { Request, Response } from "express"; +import request from "supertest"; +import { + createApiDeprecationHeadersMiddleware, + enableApiVersioning, + getApiVersionFromUrl, + openApiDocumentForVersion, +} from "./api-versioning"; + +@Controller({ path: "resource", version: "1" }) +class VersionedTestController { + @Get() + get() { + return { ok: true }; + } +} + +@Module({ controllers: [VersionedTestController] }) +class VersionedTestModule {} + +describe("API versioning helpers", () => { + it("extracts a URI version without treating unversioned paths as v1", () => { + expect(getApiVersionFromUrl("/api/v1/intents?limit=10")).toBe("1"); + expect(getApiVersionFromUrl("/api/v2/intents")).toBe("2"); + expect(getApiVersionFromUrl("/metrics")).toBe("unversioned"); + }); + + it("filters OpenAPI paths to the requested URI version", () => { + const document = { + openapi: "3.0.0", + info: { title: "Vortex", version: "0.1.0" }, + paths: { + "/api/v1/intents": {}, + "/api/v2/intents": {}, + "/health": {}, + }, + } as never; + + const v1Document = openApiDocumentForVersion(document, "1"); + const v2Document = openApiDocumentForVersion(document, "2"); + + expect(Object.keys(v1Document.paths)).toEqual(["/api/v1/intents"]); + expect(v1Document.info.version).toBe("1"); + expect(Object.keys(v2Document.paths)).toEqual(["/api/v2/intents"]); + }); + + it("adds configured Deprecation, Sunset, and deprecation Link headers", () => { + const setHeader = jest.fn(); + const next = jest.fn(); + const middleware = createApiDeprecationHeadersMiddleware({ + "1": { + deprecatedAt: "2026-01-01T00:00:00Z", + sunsetAt: "2027-01-01T00:00:00Z", + deprecationLink: "https://example.test/deprecations/v1", + }, + }); + + middleware( + { path: "/api/v1/intents" } as Request, + { setHeader } as unknown as Response, + next, + ); + + expect(setHeader).toHaveBeenNthCalledWith(1, "Deprecation", "@1767225600"); + expect(setHeader).toHaveBeenNthCalledWith(2, "Sunset", "Fri, 01 Jan 2027 00:00:00 GMT"); + expect(setHeader).toHaveBeenNthCalledWith( + 3, + "Link", + '; rel="deprecation"', + ); + expect(next).toHaveBeenCalledTimes(1); + }); + + it("leaves unversioned routes and invalid lifecycle dates untouched", () => { + const setHeader = jest.fn(); + const next = jest.fn(); + const middleware = createApiDeprecationHeadersMiddleware({ + "1": { deprecatedAt: "not-a-date", sunsetAt: "also-not-a-date" }, + }); + + middleware( + { path: "/metrics" } as Request, + { setHeader } as unknown as Response, + next, + ); + + expect(setHeader).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledTimes(1); + }); + + it("routes controller metadata under api/vN and generates matching OpenAPI paths", async () => { + const app: INestApplication = await NestFactory.create(VersionedTestModule, { logger: false }); + enableApiVersioning(app); + app.use( + createApiDeprecationHeadersMiddleware({ + "1": { deprecatedAt: "2026-01-01T00:00:00Z" }, + }), + ); + await app.init(); + + try { + const response = await request(app.getHttpServer()).get("/api/v1/resource").expect(200); + expect(response.body).toEqual({ ok: true }); + expect(response.headers.deprecation).toBe("@1767225600"); + await request(app.getHttpServer()).get("/api/v2/resource").expect(404); + + const document = SwaggerModule.createDocument(app, new DocumentBuilder().build()); + const v1Document = openApiDocumentForVersion(document, "1"); + expect(v1Document.paths["/api/v1/resource"]).toBeDefined(); + expect(v1Document.paths["/api/v2/resource"]).toBeUndefined(); + } finally { + await app.close(); + } + }); +}); \ No newline at end of file diff --git a/src/common/api-versioning.ts b/src/common/api-versioning.ts new file mode 100644 index 00000000..2c042c54 --- /dev/null +++ b/src/common/api-versioning.ts @@ -0,0 +1,76 @@ +import { INestApplication, VersioningType } from "@nestjs/common"; +import { OpenAPIObject } from "@nestjs/swagger"; +import { Request, RequestHandler, Response } from "express"; + +export const API_VERSION_PREFIX = "api/v"; +export const API_VERSIONS = ["1", "2"] as const; + +export interface ApiVersionLifecycle { + deprecatedAt?: string; + sunsetAt?: string; + deprecationLink?: string; +} + +export function enableApiVersioning(app: INestApplication): void { + app.enableVersioning({ + type: VersioningType.URI, + prefix: API_VERSION_PREFIX, + }); +} + +export function getApiVersionFromUrl(url: string): string { + return url.match(/^\/api\/v(\d+)(?:\/|\?|$)/)?.[1] ?? "unversioned"; +} + +export function openApiDocumentForVersion(document: OpenAPIObject, version: string): OpenAPIObject { + const versionPath = `/api/v${version}`; + const paths = Object.fromEntries( + Object.entries(document.paths).filter( + ([path]) => path === versionPath || path.startsWith(`${versionPath}/`), + ), + ); + + return { + ...document, + info: { ...document.info, version }, + paths, + }; +} + +function readVersionLifecycleFromEnvironment(): Record { + return { + "1": { + deprecatedAt: process.env.API_V1_DEPRECATED_AT, + sunsetAt: process.env.API_V1_SUNSET_AT, + deprecationLink: process.env.API_V1_DEPRECATION_LINK, + }, + }; +} + +function parseDate(value?: string): Date | undefined { + if (!value) return undefined; + const timestamp = Date.parse(value); + return Number.isNaN(timestamp) ? undefined : new Date(timestamp); +} + +export function createApiDeprecationHeadersMiddleware( + lifecycle: Record = readVersionLifecycleFromEnvironment(), +): RequestHandler { + return (request: Request, response: Response, next) => { + const version = getApiVersionFromUrl(request.path); + const policy = lifecycle[version]; + if (!policy) return next(); + + const deprecatedAt = parseDate(policy.deprecatedAt); + const sunsetAt = parseDate(policy.sunsetAt); + if (deprecatedAt) { + response.setHeader("Deprecation", `@${Math.floor(deprecatedAt.getTime() / 1000)}`); + } + if (sunsetAt) response.setHeader("Sunset", sunsetAt.toUTCString()); + if (policy.deprecationLink) { + response.setHeader("Link", `<${policy.deprecationLink}>; rel="deprecation"`); + } + + next(); + }; +} \ No newline at end of file diff --git a/src/datasets/datasets.controller.ts b/src/datasets/datasets.controller.ts index f6561bcb..9740fc92 100644 --- a/src/datasets/datasets.controller.ts +++ b/src/datasets/datasets.controller.ts @@ -3,7 +3,7 @@ import { ApiOperation, ApiTags } from "@nestjs/swagger"; import { DatasetsService } from "./datasets.service"; @ApiTags("datasets") -@Controller("api/v1/datasets") +@Controller({ path: "datasets", version: "1" }) export class DatasetsController { constructor(private readonly datasetsService: DatasetsService) {} diff --git a/src/disputes/disputes.controller.ts b/src/disputes/disputes.controller.ts index a7ee4fa0..3bab7298 100644 --- a/src/disputes/disputes.controller.ts +++ b/src/disputes/disputes.controller.ts @@ -17,7 +17,7 @@ import { SubmitDisputeDto } from "./dto/submit-dispute.dto"; import { DecideDisputeDto } from "./dto/decide-dispute.dto"; @ApiTags("disputes") -@Controller("api/v1/solvers/disputes") +@Controller({ path: "solvers/disputes", version: "1" }) export class DisputesController { constructor( private readonly disputesService: DisputesService, diff --git a/src/governance/guardian.controller.ts b/src/governance/guardian.controller.ts index 0b917678..cd2568c8 100644 --- a/src/governance/guardian.controller.ts +++ b/src/governance/guardian.controller.ts @@ -13,7 +13,7 @@ export class GuardianOverrideDto { } @ApiTags("governance") -@Controller("api/v1/governance/guardian") +@Controller({ path: "governance/guardian", version: "1" }) export class GuardianController { constructor(private readonly guardian: GuardianService) {} diff --git a/src/governance/params.controller.ts b/src/governance/params.controller.ts index 9946761f..23e17dc6 100644 --- a/src/governance/params.controller.ts +++ b/src/governance/params.controller.ts @@ -10,7 +10,7 @@ import { ProtocolParamsService, ParamsApiResponse } from "./params.service"; * @see ProtocolParamsService */ @ApiTags("governance") -@Controller("api/v1/params") +@Controller({ path: "params", version: "1" }) export class ParamsController { constructor(private readonly paramsService: ProtocolParamsService) {} diff --git a/src/intents/intents-sse.controller.ts b/src/intents/intents-sse.controller.ts index cba1b074..7f105679 100644 --- a/src/intents/intents-sse.controller.ts +++ b/src/intents/intents-sse.controller.ts @@ -21,7 +21,7 @@ import { logger } from "../common/logger"; * * RFQ remains WebSocket-only — this endpoint is a read-only intent feed. */ -@Controller("api/v1/stream") +@Controller({ path: "stream", version: "1" }) export class IntentsSseController { constructor( private readonly feed: IntentFeedService, diff --git a/src/intents/intents.controller.ts b/src/intents/intents.controller.ts index d7265476..7962f140 100644 --- a/src/intents/intents.controller.ts +++ b/src/intents/intents.controller.ts @@ -74,7 +74,7 @@ import { AppConfig } from "../config/configuration"; import { isCanaryIntent } from "../common/canary"; @ApiTags("intents") -@Controller("api/v1/intents") +@Controller({ path: "intents", version: "1" }) export class IntentsController { constructor( private readonly intentsService: IntentsService, diff --git a/src/intents/slashes.controller.ts b/src/intents/slashes.controller.ts index 2c86d22f..ece98c0e 100644 --- a/src/intents/slashes.controller.ts +++ b/src/intents/slashes.controller.ts @@ -20,7 +20,7 @@ import { SlashingPipelineService } from "./slashing-pipeline.service"; * solver's fill-proof challenge. */ @ApiTags("slashes") -@Controller("api/v1/slashes") +@Controller({ path: "slashes", version: "1" }) export class SlashesController { constructor(private readonly pipeline: SlashingPipelineService) {} @@ -50,7 +50,7 @@ export class SlashesController { */ @ApiTags("admin") @ApiHeader({ name: "x-admin-key", required: true }) -@Controller("api/v1/admin/slashes") +@Controller({ path: "admin/slashes", version: "1" }) @UseGuards(AdminGuard) @RequireAdminRole("admin") export class AdminSlashesController { diff --git a/src/killswitch/killswitch.controller.ts b/src/killswitch/killswitch.controller.ts index 8d059beb..164497bb 100644 --- a/src/killswitch/killswitch.controller.ts +++ b/src/killswitch/killswitch.controller.ts @@ -22,7 +22,7 @@ import { OperatorGuard, OperatorRequest } from "./operator.guard"; */ @ApiTags("ops/killswitch") @UseGuards(OperatorGuard) -@Controller("api/v1/ops/killswitch") +@Controller({ path: "ops/killswitch", version: "1" }) export class KillSwitchController { constructor(private readonly killSwitch: KillSwitchService) {} diff --git a/src/main.ts b/src/main.ts index ffe23961..ad4fea0b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -17,6 +17,12 @@ import { IntentsSweeperService } from "./intents/intents-sweeper.service"; import { BODY_SIZE_LIMIT, JSON_MAX_DEPTH } from "./config/limits.config"; import { JobsService } from "./jobs/jobs.service"; import { adminAuthMiddleware } from "./admin/admin.guard"; +import { + API_VERSIONS, + createApiDeprecationHeadersMiddleware, + enableApiVersioning, + openApiDocumentForVersion, +} from "./common/api-versioning"; // Initialise Sentry before the NestJS app boots so that any startup errors // are also captured. No-op when SENTRY_DSN is not set. @@ -66,6 +72,7 @@ function checkContractIdEnvVars( async function bootstrap() { const app = await NestFactory.create(AppModule); + enableApiVersioning(app); // Issue #20 — trust the first proxy hop so Helmet/HSTS sees the real // forwarded protocol when TLS terminates upstream behind nginx/ALB. @@ -116,6 +123,7 @@ async function bootstrap() { next(); }); + app.use(createApiDeprecationHeadersMiddleware()); // Issue #43 / #302 — verify the security headers we rely on in production. // HSTS is explicitly configured so it is not silently skipped when a TLS @@ -176,11 +184,19 @@ async function bootstrap() { .setDescription("Intent relay API + WebSocket feed for Vortex Protocol") .setVersion("0.1.0") .build(); - const swaggerDocument = SwaggerModule.createDocument(app, swaggerConfig); + const allVersionsDocument = SwaggerModule.createDocument(app, swaggerConfig); + const swaggerDocuments = Object.fromEntries( + API_VERSIONS.map((version) => [version, openApiDocumentForVersion(allVersionsDocument, version)]), + ); const shouldServeSwagger = process.env.NODE_ENV !== "production"; if (shouldServeSwagger) { - SwaggerModule.setup("docs", app, swaggerDocument); + SwaggerModule.setup("docs", app, swaggerDocuments["1"]); + for (const version of API_VERSIONS) { + SwaggerModule.setup(`docs/v${version}`, app, swaggerDocuments[version], { + jsonDocumentUrl: `docs/v${version}-json`, + }); + } } const configService = app.get(ConfigService); diff --git a/src/metrics/metrics.interceptor.ts b/src/metrics/metrics.interceptor.ts index fda49e85..8faddfa1 100644 --- a/src/metrics/metrics.interceptor.ts +++ b/src/metrics/metrics.interceptor.ts @@ -1,6 +1,7 @@ import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from "@nestjs/common"; import { Observable, finalize } from "rxjs"; import { MetricsService } from "./metrics.service"; +import { getApiVersionFromUrl } from "../common/api-versioning"; @Injectable() export class MetricsInterceptor implements NestInterceptor { @@ -12,6 +13,8 @@ export class MetricsInterceptor implements NestInterceptor { const start = Date.now(); const method = request.method; const route = request.route?.path || request.originalUrl || request.url || "unknown"; + const apiVersion = getApiVersionFromUrl(request.originalUrl ?? request.url ?? ""); + const version = apiVersion === "unversioned" ? apiVersion : `v${apiVersion}`; return next.handle().pipe( // finalize (not tap) so 5xx thrown as exceptions are still counted. @@ -19,12 +22,13 @@ export class MetricsInterceptor implements NestInterceptor { const statusCode = response.statusCode ?? 500; const duration = (Date.now() - start) / 1000; - this.metricsService.httpRequestTotal.inc({ method, route, status_code: statusCode }); - this.metricsService.httpRequestDuration.observe({ method, route, status_code: statusCode }, duration); + const labels = { method, route, status_code: statusCode, version }; + this.metricsService.httpRequestTotal.inc(labels); + this.metricsService.httpRequestDuration.observe(labels, duration); if (statusCode >= 500) { - this.metricsService.httpRequestErrors.inc({ method, route, status_code: statusCode }); + this.metricsService.httpRequestErrors.inc(labels); } - if (route.includes("/api/v1/intents") && method === "POST") { + if (apiVersion === "1" && method === "POST" && /^\/api\/v1\/intents(?:\?|$)/.test(request.originalUrl ?? "")) { this.metricsService.observeIntentCreate(duration); } }), diff --git a/src/metrics/metrics.service.ts b/src/metrics/metrics.service.ts index 4cc69f14..09d09193 100644 --- a/src/metrics/metrics.service.ts +++ b/src/metrics/metrics.service.ts @@ -94,7 +94,7 @@ export class MetricsService implements OnModuleInit { this.httpRequestDuration = new client.Histogram({ name: `${prefix}http_request_duration_seconds`, help: "HTTP request duration in seconds", - labelNames: ["method", "route", "status_code"], + labelNames: ["method", "route", "status_code", "version"], buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10], registers: [this.register], }); @@ -102,14 +102,14 @@ export class MetricsService implements OnModuleInit { this.httpRequestTotal = new client.Counter({ name: `${prefix}http_requests_total`, help: "Total number of HTTP requests", - labelNames: ["method", "route", "status_code"], + labelNames: ["method", "route", "status_code", "version"], registers: [this.register], }); this.httpRequestErrors = new client.Counter({ name: `${prefix}http_request_errors_total`, help: "Total number of HTTP request errors (5xx)", - labelNames: ["method", "route", "status_code"], + labelNames: ["method", "route", "status_code", "version"], registers: [this.register], }); diff --git a/src/solvers/solvers.controller.ts b/src/solvers/solvers.controller.ts index e69de29b..2e1bd1ca 100644 --- a/src/solvers/solvers.controller.ts +++ b/src/solvers/solvers.controller.ts @@ -0,0 +1,290 @@ +import { + BadRequestException, + Body, + Controller, + ForbiddenException, + Get, + NotFoundException, + Param, + Patch, + Post, + Query, +} from "@nestjs/common"; +import { + ApiBadRequestResponse, + ApiNotFoundResponse, + ApiOkResponse, + ApiOperation, + ApiQuery, + ApiTags, + ApiUnauthorizedResponse, +} from "@nestjs/swagger"; +import { IntentsService } from "../intents/intents.service"; +import { + buildDisputeMessage, + buildRegisterMessage, + buildUpdateSolverMessage, + buildSolverStatusMessage, + verifyStellarSignature, +} from "../common/stellar-signature"; +import { SolversService, LeaderboardWindow, solverSupports } from "./solvers.service"; +import { ListIntentsDto } from "../intents/dto/list-intents.dto"; +import { RegisterSolverDto } from "./dto/register-solver.dto"; +import { UpdateSolverDto } from "./dto/update-solver.dto"; +import { UpdateSolverStatusDto } from "./dto/update-solver-status.dto"; + +const WINDOW_SECONDS: Record, number> = { + "24h": 24 * 60 * 60, + "7d": 7 * 24 * 60 * 60, + "30d": 30 * 24 * 60 * 60, +}; + +@ApiTags("solvers") +@Controller({ path: "solvers", version: "1" }) +export class SolversController { + constructor( + private readonly solversService: SolversService, + private readonly intentsService: IntentsService, + ) {} + + @Post() + async register(@Body() dto: RegisterSolverDto) { + verifyStellarSignature(dto.address, buildRegisterMessage(dto.address), dto.proofSignature); + + return this.solversService.register({ + address: dto.address, + name: dto.name, + bondAmount: dto.bondAmount, + avgFillTime: dto.avgFillTime, + isActive: true, + supportedChains: dto.supportedChains, + supportedTokens: dto.supportedTokens, + }); + } + + @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"] }) + 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 getLegacyLeaderboard() { + const solvers = (await this.solversService.getAll()).sort( + (a, b) => b.fillsCompleted - a.fillsCompleted, + ); + return { solvers, count: solvers.length }; + } + + @Get(":address/eligible-intents") + async getEligibleIntents(@Param("address") address: string, @Query() dto: ListIntentsDto) { + const solver = await this.solversService.get(address); + if (!solver) throw new NotFoundException("Solver not found"); + if (!solver.isActive) throw new ForbiddenException("Solver is not active"); + + const open = await this.intentsService.getByState("open"); + const eligible = open.filter((intent) => solverSupports(solver, intent.srcChain, intent.srcToken.symbol)); + const limit = Math.min(dto.limit ?? 20, 100); + const offset = dto.offset ?? 0; + if ((dto.limit ?? 20) > 100) { + throw new BadRequestException("Limit exceeds maximum allowed value of 100"); + } + + const page = eligible.slice(offset, offset + limit); + return { intents: page, total: eligible.length, count: eligible.length, limit, offset }; + } + + @Get(":address") + async getSolver(@Param("address") address: string) { + const solver = await this.solversService.get(address); + if (!solver) throw new NotFoundException("Solver not found"); + return solver; + } + + @Patch(":address") + @ApiOkResponse({ description: "Updated solver record" }) + @ApiBadRequestResponse({ description: "Invalid update body" }) + @ApiUnauthorizedResponse({ description: "Missing or invalid signature" }) + @ApiNotFoundResponse({ description: "Solver not found" }) + async updateSolver(@Param("address") address: string, @Body() dto: UpdateSolverDto) { + verifyStellarSignature(address, buildUpdateSolverMessage(address), dto.signature); + const { signature: _signature, ...patch } = dto; + const solver = await this.solversService.update(address, patch); + if (!solver) throw new NotFoundException("Solver not found"); + return solver; + } + + @Get(":address/stats") + 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 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 completed = recentIntents.filter((intent) => intent.state === "filled"); + const fillsCompleted = completed.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); + + return { + address: solver.address, + name: solver.name, + fillsCompleted, + fillsFailed, + successRate: Number(successRate.toFixed(4)), + reputationScore: Number((successRate * Math.exp(-ageDays / 180)).toFixed(4)), + totalVolume: completed.reduce((sum, intent) => sum + BigInt(intent.fillAmount ?? "0"), 0n).toString(), + avgFillTime: completed.filter((intent) => intent.filledAt != null).length + ? Math.round( + completed + .filter((intent) => intent.filledAt != null) + .reduce((sum, intent) => sum + (intent.filledAt! - intent.createdAt), 0) / + completed.filter((intent) => 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"); + return this.solversService.getSlashHistory(address, Number(page) || 1, Number(pageSize) || 25); + } + + @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, @Body() dto: UpdateSolverStatusDto) { + verifyStellarSignature(address, buildSolverStatusMessage("deregister", address), dto.signature); + const solver = await this.solversService.deregister(address); + if (!solver) throw new NotFoundException("Solver not found"); + return { + ...solver, + withdrawalStatus: "pending", + withdrawalRequestedAt: Math.floor(Date.now() / 1000), + }; + } + + @Post(":address/deactivate") + async deactivate(@Param("address") address: string, @Body() dto: UpdateSolverStatusDto) { + verifyStellarSignature(address, buildSolverStatusMessage("deactivate", address), dto.signature); + const solver = await this.solversService.deactivate(address); + if (!solver) throw new NotFoundException("Solver not found"); + return solver; + } + + @Post(":address/reactivate") + async reactivate(@Param("address") address: string, @Body() dto: UpdateSolverStatusDto) { + verifyStellarSignature(address, buildSolverStatusMessage("reactivate", address), dto.signature); + const solver = await this.solversService.reactivate(address); + 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/soroban/outbox-admin.controller.ts b/src/soroban/outbox-admin.controller.ts index a3da1a62..e8f7927a 100644 --- a/src/soroban/outbox-admin.controller.ts +++ b/src/soroban/outbox-admin.controller.ts @@ -11,7 +11,7 @@ import { IOutboxRepository, OUTBOX_REPOSITORY } from "./outbox.repository"; */ @ApiTags("admin") @ApiHeader({ name: "x-admin-key", required: true }) -@Controller("api/v1/admin/outbox") +@Controller({ path: "admin/outbox", version: "1" }) @UseGuards(AdminGuard) @RequireAdminRole("admin") export class OutboxAdminController { diff --git a/src/soroban/shadow.controller.ts b/src/soroban/shadow.controller.ts index 2d0a634d..7b47aa91 100644 --- a/src/soroban/shadow.controller.ts +++ b/src/soroban/shadow.controller.ts @@ -43,7 +43,7 @@ export class ShadowReportQueryDto { * describes internal consistency is not something to expose publicly. */ @ApiTags("admin") -@Controller("api/v1/admin") +@Controller({ path: "admin", version: "1" }) export class ShadowController { constructor(private readonly shadowService: ShadowService) {} diff --git a/src/soroban/soroban.controller.ts b/src/soroban/soroban.controller.ts index 2cb5cbb2..90cb0b2b 100644 --- a/src/soroban/soroban.controller.ts +++ b/src/soroban/soroban.controller.ts @@ -11,7 +11,7 @@ import { SorobanService } from "./soroban.service"; import { AccountRateLimitGuard } from "./account-rate-limit.guard"; @ApiTags("chain") -@Controller("api/v1/chain") +@Controller({ path: "chain", version: "1" }) export class SorobanController { constructor(private readonly sorobanService: SorobanService) {} diff --git a/src/stats/stats.controller.ts b/src/stats/stats.controller.ts index c3261e7c..a825f29a 100644 --- a/src/stats/stats.controller.ts +++ b/src/stats/stats.controller.ts @@ -3,7 +3,7 @@ import { ApiOperation, ApiTags } from "@nestjs/swagger"; import { StatsService } from "./stats.service"; @ApiTags("stats") -@Controller("api/v1/stats") +@Controller({ path: "stats", version: "1" }) export class StatsController { constructor(private readonly statsService: StatsService) {} diff --git a/src/tokens/admin-tokens.controller.ts b/src/tokens/admin-tokens.controller.ts index 421c9b1f..49e60cf2 100644 --- a/src/tokens/admin-tokens.controller.ts +++ b/src/tokens/admin-tokens.controller.ts @@ -14,7 +14,7 @@ import { CreateAdminTokenDto, DeleteAdminTokenDto, PatchAdminTokenDto } from "./ */ @ApiTags("admin") @ApiHeader({ name: "x-admin-key", required: true }) -@Controller("api/v1/admin/tokens") +@Controller({ path: "admin/tokens", version: "1" }) @UseGuards(AdminGuard) @RequireAdminRole("admin") export class AdminTokensController { diff --git a/src/tokens/tokens.controller.ts b/src/tokens/tokens.controller.ts index 31c5e912..7926709f 100644 --- a/src/tokens/tokens.controller.ts +++ b/src/tokens/tokens.controller.ts @@ -4,7 +4,7 @@ import { TokensService } from "./tokens.service"; import { StellarTokensResponseDto } from "./dto/token-response.dto"; @ApiTags("tokens") -@Controller("api/v1/tokens") +@Controller({ path: "tokens", version: "1" }) export class TokensController { constructor(private readonly tokensService: TokensService) {} diff --git a/src/treasury/treasury.controller.ts b/src/treasury/treasury.controller.ts index 9b69d9dc..cd355c39 100644 --- a/src/treasury/treasury.controller.ts +++ b/src/treasury/treasury.controller.ts @@ -24,7 +24,7 @@ import { ReconciliationSummary, ReconciliationDetailResponse } from "./treasury. * Public and admin endpoints for treasury reconciliation data. */ @ApiTags("treasury") -@Controller("api/v1/treasury") +@Controller({ path: "treasury", version: "1" }) export class TreasuryController { constructor(private readonly treasuryService: TreasuryService) {} diff --git a/test/metrics.e2e-spec.ts b/test/metrics.e2e-spec.ts index d6848156..6db6b89d 100644 --- a/test/metrics.e2e-spec.ts +++ b/test/metrics.e2e-spec.ts @@ -60,4 +60,11 @@ describe("MetricsController (e2e)", () => { expect(res.text).toContain("vortex_process_cpu_seconds"); }); }); + + it("labels HTTP request metrics with the routed API version", async () => { + await request(app.getHttpServer()).get("/api/v1/stats").expect(200); + const res = await request(app.getHttpServer()).get("/metrics").expect(200); + + expect(res.text).toContain('version="v1"'); + }); }); diff --git a/test/openapi-contract.e2e-spec.ts b/test/openapi-contract.e2e-spec.ts index 4b659ebf..0d72a27b 100644 --- a/test/openapi-contract.e2e-spec.ts +++ b/test/openapi-contract.e2e-spec.ts @@ -40,6 +40,16 @@ describe("OpenAPI contract (e2e)", () => { expect(res.body.info.title).toBe("Vortex Backend"); }); + it("serves separate OpenAPI documents for v1 and v2", async () => { + const v1 = await request(app.getHttpServer()).get("/docs/v1-json").expect(200); + const v2 = await request(app.getHttpServer()).get("/docs/v2-json").expect(200); + + expect(v1.body.paths["/api/v1/intents"]).toBeDefined(); + expect(v1.body.paths["/health"]).toBeUndefined(); + expect(v2.body.info.version).toBe("2"); + expect(Object.keys(v2.body.paths)).toEqual([]); + }); + it("declares all intent endpoints", async () => { const res = await request(app.getHttpServer()).get("/docs-json").expect(200); const paths: Record = res.body.paths; diff --git a/test/utils/create-test-app.ts b/test/utils/create-test-app.ts index c8d97ff2..39ea415f 100644 --- a/test/utils/create-test-app.ts +++ b/test/utils/create-test-app.ts @@ -10,6 +10,12 @@ import { AppConfig } from "../../src/config/configuration"; import { HttpExceptionFilter } from "../../src/common/http-exception.filter"; import { PrismaService } from "../../src/prisma/prisma.service"; import { BODY_SIZE_LIMIT, JSON_MAX_DEPTH } from "../../src/config/limits.config"; +import { + API_VERSIONS, + createApiDeprecationHeadersMiddleware, + enableApiVersioning, + openApiDocumentForVersion, +} from "../../src/common/api-versioning"; /** * Minimal PrismaService stand-in for e2e tests. @@ -39,6 +45,8 @@ export async function createTestApp(): Promise { .compile(); const app = moduleRef.createNestApplication(); + enableApiVersioning(app); + app.use(createApiDeprecationHeadersMiddleware()); // Mirror the production body-size limit so 413 tests behave correctly app.use(json({ limit: BODY_SIZE_LIMIT })); @@ -88,11 +96,16 @@ export async function createTestApp(): Promise { .setDescription("Intent relay API + WebSocket feed for Vortex Protocol") .setVersion("0.1.0") .build(); - SwaggerModule.setup( - "docs", - app, - SwaggerModule.createDocument(app, swaggerConfig), - ); + const allVersionsDocument = SwaggerModule.createDocument(app, swaggerConfig); + SwaggerModule.setup("docs", app, allVersionsDocument); + for (const version of API_VERSIONS) { + SwaggerModule.setup( + `docs/v${version}`, + app, + openApiDocumentForVersion(allVersionsDocument, version), + { jsonDocumentUrl: `docs/v${version}-json` }, + ); + } // Wire CORS the same way main.ts does so the e2e environment is faithful. const configService = app.get(ConfigService);