From 39f680faefe9c06aa85eb95603c3de22d6684420 Mon Sep 17 00:00:00 2001 From: valentinahenry37-droid Date: Fri, 28 Aug 2026 18:53:26 +0100 Subject: [PATCH] Harden solver control and quote limits --- src/common/stellar-signature.ts | 7 +++++++ src/intents/intents.controller.ts | 3 ++- src/solvers/dto/register-solver.dto.ts | 4 +++- src/solvers/dto/update-solver-status.dto.ts | 9 +++++++++ src/solvers/solvers.controller.ts | 11 ++++++++--- src/soroban/signer.service.ts | 5 ++++- 6 files changed, 33 insertions(+), 6 deletions(-) create mode 100644 src/solvers/dto/update-solver-status.dto.ts diff --git a/src/common/stellar-signature.ts b/src/common/stellar-signature.ts index f23f357..c1313e8 100644 --- a/src/common/stellar-signature.ts +++ b/src/common/stellar-signature.ts @@ -66,3 +66,10 @@ export function buildFillMessage(intentId: string, solver: string): string { export function buildRegisterMessage(address: string): string { return `register:${address}`; } + +/** + * Build the canonical message that a solver must sign to change status. + */ +export function buildSolverStatusMessage(action: "deactivate" | "reactivate" | "deregister", address: string): string { + return `${action}:${address}`; +} diff --git a/src/intents/intents.controller.ts b/src/intents/intents.controller.ts index ac92acc..7f33dfd 100644 --- a/src/intents/intents.controller.ts +++ b/src/intents/intents.controller.ts @@ -280,8 +280,9 @@ export class IntentsController { * Issue #44 — document 429 on quote too, since it's under the global guard. */ @Post("quote") + @Throttle({ default: { limit: 20, ttl: 60_000 } }) @ApiTooManyRequestsResponse({ - description: "Rate limit exceeded — max 100 req/min per IP globally", + description: "Rate limit exceeded — max 20 quote requests per 60 s per IP", }) @ApiOkResponse({ type: QuoteResponseDto }) quote(@Body() dto: QuoteRequestDto): QuoteResponseDto { diff --git a/src/solvers/dto/register-solver.dto.ts b/src/solvers/dto/register-solver.dto.ts index 4bff265..df28e7e 100644 --- a/src/solvers/dto/register-solver.dto.ts +++ b/src/solvers/dto/register-solver.dto.ts @@ -1,4 +1,4 @@ -import { IsIn, IsInt, IsNotEmpty, IsString, MinLength, Min, IsArray } from "class-validator"; +import { IsIn, IsInt, IsNotEmpty, IsString, MinLength, Min, IsArray, ArrayMaxSize } from "class-validator"; import { ApiProperty } from "@nestjs/swagger"; import { SupportedChain } from "../../intents/intents.types"; @@ -35,11 +35,13 @@ export class RegisterSolverDto { @ApiProperty({ enum: SUPPORTED_CHAINS, isArray: true, description: "Chains this solver supports" }) @IsArray() + @ArrayMaxSize(8) @IsIn(SUPPORTED_CHAINS, { each: true }) supportedChains!: SupportedChain[]; @ApiProperty({ isArray: true, description: "Token symbols this solver supports" }) @IsArray() + @ArrayMaxSize(32) @IsString({ each: true }) supportedTokens!: string[]; } diff --git a/src/solvers/dto/update-solver-status.dto.ts b/src/solvers/dto/update-solver-status.dto.ts new file mode 100644 index 0000000..6a2e9f1 --- /dev/null +++ b/src/solvers/dto/update-solver-status.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsNotEmpty, IsString } from "class-validator"; + +export class UpdateSolverStatusDto { + @ApiProperty({ description: "Proof-of-control signature for the solver status update" }) + @IsString() + @IsNotEmpty() + signature!: string; +} diff --git a/src/solvers/solvers.controller.ts b/src/solvers/solvers.controller.ts index 33d8352..daf4f75 100644 --- a/src/solvers/solvers.controller.ts +++ b/src/solvers/solvers.controller.ts @@ -9,6 +9,8 @@ import { import { ApiTags } from "@nestjs/swagger"; import { SolversService } 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"; @ApiTags("solvers") @Controller("api/v1/solvers") @@ -70,21 +72,24 @@ export class SolversController { } @Post(":address/deregister") - deregisterSolver(@Param("address") address: string) { + deregisterSolver(@Param("address") address: string, @Body() dto: UpdateSolverStatusDto) { + verifyStellarSignature(address, buildSolverStatusMessage("deregister", address), dto.signature); const solver = this.solversService.deregister(address); if (!solver) throw new NotFoundException("Solver not found"); return { ...solver, withdrawalStatus: "pending" }; } @Post(":address/deactivate") - deactivate(@Param("address") address: string) { + deactivate(@Param("address") address: string, @Body() dto: UpdateSolverStatusDto) { + verifyStellarSignature(address, buildSolverStatusMessage("deactivate", address), dto.signature); const solver = this.solversService.deactivate(address); if (!solver) throw new NotFoundException("Solver not found"); return solver; } @Post(":address/reactivate") - reactivate(@Param("address") address: string) { + reactivate(@Param("address") address: string, @Body() dto: UpdateSolverStatusDto) { + verifyStellarSignature(address, buildSolverStatusMessage("reactivate", address), dto.signature); const solver = this.solversService.reactivate(address); if (!solver) throw new NotFoundException("Solver not found"); return solver; diff --git a/src/soroban/signer.service.ts b/src/soroban/signer.service.ts index 1b3d08c..7c3a4e6 100644 --- a/src/soroban/signer.service.ts +++ b/src/soroban/signer.service.ts @@ -1,6 +1,7 @@ import { Injectable } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { Keypair, Networks, Transaction, FeeBumpTransaction } from "@stellar/stellar-sdk"; +import { readFileSync } from "node:fs"; import { AppConfig } from "../config/configuration"; import { SorobanService } from "./soroban.service"; @@ -44,7 +45,9 @@ export class SignerService { configService: ConfigService, private readonly sorobanService: SorobanService, ) { - this.secretKey = configService.get("stellar.signingKey", { infer: true }); + const configuredSecret = configService.get("stellar.signingKey", { infer: true }); + const secretFile = process.env.SOROBAN_SIGNING_KEY_FILE?.trim(); + this.secretKey = secretFile ? readFileSync(secretFile, "utf8").trim() : configuredSecret; this.networkPassphrase = NETWORK_PASSPHRASES[configService.get("stellar.network", { infer: true })]; }