diff --git a/scripts/solver-bot.ts b/scripts/solver-bot.ts index 78a9b59..3b424db 100644 --- a/scripts/solver-bot.ts +++ b/scripts/solver-bot.ts @@ -1,9 +1,24 @@ // Reference solver bot - see scripts/README.md. import WebSocket from "ws"; +import { Keypair } from "@stellar/stellar-sdk"; +import { buildAcceptMessage, buildFillMessage } from "../src/common/stellar-signature"; const API_BASE = process.env.API_BASE ?? "http://localhost:4000"; const WS_URL = process.env.WS_URL ?? "ws://localhost:4000/ws"; -const SOLVER_ADDRESS = process.env.SOLVER_ADDRESS ?? "SOLVER_ALPHA"; + +// The bot requires a Stellar secret key (S...) to sign requests. +// Set SOLVER_SECRET to the solver keypair's secret key. +// The derived public key (G...) is used as the solver address. +const SOLVER_SECRET = process.env.SOLVER_SECRET; +if (!SOLVER_SECRET) { + console.error("[solver-bot] SOLVER_SECRET env var is required (Stellar secret key S...)"); + process.exit(1); +} + +const keypair = Keypair.fromSecret(SOLVER_SECRET); +const SOLVER_ADDRESS = keypair.publicKey(); + +console.log(`[solver-bot] solver address: ${SOLVER_ADDRESS}`); interface Intent { intentId: string; @@ -12,11 +27,19 @@ interface Intent { deadline: number; } +/** Sign a UTF-8 message with the solver keypair; return base64 signature. */ +function sign(message: string): string { + return keypair.sign(Buffer.from(message, "utf8")).toString("base64"); +} + async function acceptIntent(intentId: string): Promise { + const message = buildAcceptMessage(intentId, SOLVER_ADDRESS); + const signature = sign(message); + const res = await fetch(`${API_BASE}/api/v1/intents/${intentId}/accept`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ solver: SOLVER_ADDRESS }), + body: JSON.stringify({ solver: SOLVER_ADDRESS, signature }), }); if (!res.ok) { console.log(`[solver-bot] accept ${intentId} failed: ${res.status} ${await res.text()}`); @@ -27,6 +50,9 @@ async function acceptIntent(intentId: string): Promise { } async function fillIntent(intentId: string, minDstAmount: string): Promise { + const message = buildFillMessage(intentId, SOLVER_ADDRESS); + const signature = sign(message); + const res = await fetch(`${API_BASE}/api/v1/intents/${intentId}/fill`, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -34,6 +60,7 @@ async function fillIntent(intentId: string, minDstAmount: string): Promise solver: SOLVER_ADDRESS, fillAmount: minDstAmount, txHash: `demo-${Date.now()}`, + signature, }), }); if (!res.ok) { diff --git a/src/common/stellar-signature.ts b/src/common/stellar-signature.ts new file mode 100644 index 0000000..f23f357 --- /dev/null +++ b/src/common/stellar-signature.ts @@ -0,0 +1,68 @@ +/** + * Stellar keypair signature verification helper. + * + * Convention used throughout this project: + * message = the canonical string that was signed + * signature = base64-encoded 64-byte Ed25519 signature produced by + * Keypair.sign(Buffer.from(message)) + * + * The signer proves control of `publicKey` by supplying a valid signature + * over the message. We never trust a caller-supplied address alone. + */ +import { Keypair } from "@stellar/stellar-sdk"; +import { UnauthorizedException } from "@nestjs/common"; + +/** + * Verify that `signature` (base64) over `message` (utf-8) was produced by + * the private key corresponding to `publicKey` (Stellar G-address). + * + * Throws UnauthorizedException on any failure so callers can let it propagate + * straight to the HTTP layer. + */ +export function verifyStellarSignature( + publicKey: string, + message: string, + signature: string, +): void { + try { + const keypair = Keypair.fromPublicKey(publicKey); + const messageBytes = Buffer.from(message, "utf8"); + const sigBytes = Buffer.from(signature, "base64"); + const valid = keypair.verify(messageBytes, sigBytes); + if (!valid) { + throw new UnauthorizedException("Signature verification failed"); + } + } catch (err) { + if (err instanceof UnauthorizedException) throw err; + // Invalid public key, bad base64, etc. + throw new UnauthorizedException("Invalid signature or public key"); + } +} + +/** + * Build the canonical message that a user must sign to cancel an intent. + */ +export function buildCancelMessage(intentId: string): string { + return `cancel:${intentId}`; +} + +/** + * Build the canonical message that a solver must sign to accept an intent. + */ +export function buildAcceptMessage(intentId: string, solver: string): string { + return `accept:${intentId}:${solver}`; +} + +/** + * Build the canonical message that a solver must sign to fill an intent. + */ +export function buildFillMessage(intentId: string, solver: string): string { + return `fill:${intentId}:${solver}`; +} + +/** + * Build the canonical message that a solver must sign to register. + */ +export function buildRegisterMessage(address: string): string { + return `register:${address}`; +} diff --git a/src/intents/dto/accept-intent.dto.ts b/src/intents/dto/accept-intent.dto.ts index e7102db..fe63d70 100644 --- a/src/intents/dto/accept-intent.dto.ts +++ b/src/intents/dto/accept-intent.dto.ts @@ -6,4 +6,13 @@ export class AcceptIntentDto { @IsString() @MinLength(5) solver!: string; + + @ApiProperty({ + description: + 'Base64-encoded Ed25519 signature of the message "accept::" ' + + "produced by the solver's private key", + }) + @IsString() + @MinLength(10) + signature!: string; } diff --git a/src/intents/dto/cancel-intent.dto.ts b/src/intents/dto/cancel-intent.dto.ts index 3628e6a..c9abf68 100644 --- a/src/intents/dto/cancel-intent.dto.ts +++ b/src/intents/dto/cancel-intent.dto.ts @@ -6,4 +6,13 @@ export class CancelIntentDto { @IsString() @MinLength(10) user!: string; + + @ApiProperty({ + description: + 'Base64-encoded Ed25519 signature of the message "cancel:" ' + + "produced by the private key of `user`", + }) + @IsString() + @MinLength(10) + signature!: string; } diff --git a/src/intents/dto/fill-intent.dto.ts b/src/intents/dto/fill-intent.dto.ts index d40c17a..2287f41 100644 --- a/src/intents/dto/fill-intent.dto.ts +++ b/src/intents/dto/fill-intent.dto.ts @@ -16,4 +16,13 @@ export class FillIntentDto { @IsOptional() @IsString() txHash?: string; + + @ApiProperty({ + description: + 'Base64-encoded Ed25519 signature of the message "fill::" ' + + "produced by the solver's private key", + }) + @IsString() + @MinLength(10) + signature!: string; } diff --git a/src/intents/intents.controller.ts b/src/intents/intents.controller.ts index 6d3296d..d30f301 100644 --- a/src/intents/intents.controller.ts +++ b/src/intents/intents.controller.ts @@ -20,6 +20,12 @@ import { AcceptIntentDto } from "./dto/accept-intent.dto"; import { FillIntentDto } from "./dto/fill-intent.dto"; import { CancelIntentDto } from "./dto/cancel-intent.dto"; import { QuoteRequestDto } from "./dto/quote-request.dto"; +import { + verifyStellarSignature, + buildCancelMessage, + buildAcceptMessage, + buildFillMessage, +} from "../common/stellar-signature"; @ApiTags("intents") @Controller("api/v1/intents") @@ -115,6 +121,9 @@ export class IntentsController { throw new ForbiddenException("Solver not registered or inactive"); } + // Verify the solver controls the claimed address + verifyStellarSignature(dto.solver, buildAcceptMessage(id, dto.solver), dto.signature); + const updated = this.intentsService.update(id, { state: "accepted", solver: dto.solver, @@ -140,6 +149,9 @@ export class IntentsController { throw new GoneException("Fill window has expired"); } + // Verify the solver controls the claimed address + verifyStellarSignature(dto.solver, buildFillMessage(id, dto.solver), dto.signature); + const fillAmount = BigInt(dto.fillAmount); const minAmount = BigInt(intent.minDstAmount); if (fillAmount < minAmount) { @@ -174,6 +186,9 @@ export class IntentsController { throw new ConflictException(`Cannot cancel intent in state: ${intent.state}`); } + // Verify the user controls the claimed address + verifyStellarSignature(dto.user, buildCancelMessage(id), dto.signature); + const updated = this.intentsService.update(id, { state: "cancelled" }); this.intentsGateway.broadcast({ type: "intent_cancelled", intentId: id }); return updated; diff --git a/src/main.ts b/src/main.ts index a0f7c26..4fb968f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -26,6 +26,10 @@ async function bootstrap() { const configService = app.get(ConfigService); const port = configService.get("port", { infer: true }); + const corsOrigin = configService.get("corsOrigin", { infer: true }); + + app.enableCors({ origin: corsOrigin }); + await app.listen(port); console.log(`\nVortex backend (Nest) running on :${port}`); console.log(`WS → ws://localhost:${port}/ws`); diff --git a/src/solvers/dto/register-solver.dto.ts b/src/solvers/dto/register-solver.dto.ts new file mode 100644 index 0000000..5ecfeac --- /dev/null +++ b/src/solvers/dto/register-solver.dto.ts @@ -0,0 +1,54 @@ +import { IsArray, IsBoolean, IsIn, IsOptional, IsString, Matches, MinLength } from "class-validator"; +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { SupportedChain } from "../../intents/intents.types"; + +const SUPPORTED_CHAINS: SupportedChain[] = [ + "stellar", + "ethereum", + "base", + "polygon", + "arbitrum", + "optimism", + "avalanche", +]; + +export class RegisterSolverDto { + @ApiProperty({ description: "Stellar G-address of the solver" }) + @IsString() + @MinLength(10) + address!: string; + + @ApiProperty({ description: "Human-readable solver name" }) + @IsString() + @MinLength(1) + name!: string; + + @ApiProperty({ description: "Bond amount as a non-negative integer string (base units)" }) + @IsString() + @Matches(/^\d+$/) + bondAmount!: string; + + @ApiPropertyOptional({ default: false, description: "Whether the solver is immediately active" }) + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @ApiProperty({ enum: SUPPORTED_CHAINS, isArray: true, description: "Chains the solver supports" }) + @IsArray() + @IsIn(SUPPORTED_CHAINS, { each: true }) + supportedChains!: SupportedChain[]; + + @ApiProperty({ isArray: true, description: "Token symbols the solver supports" }) + @IsArray() + @IsString({ each: true }) + supportedTokens!: string[]; + + @ApiProperty({ + description: + 'Base64-encoded Ed25519 signature of the message "register:
" ' + + "produced by the solver's private key — proves key ownership", + }) + @IsString() + @MinLength(10) + signature!: string; +} diff --git a/src/solvers/solvers.controller.ts b/src/solvers/solvers.controller.ts index b2604b7..6537ade 100644 --- a/src/solvers/solvers.controller.ts +++ b/src/solvers/solvers.controller.ts @@ -1,12 +1,31 @@ -import { Controller, Get, NotFoundException, Param } from "@nestjs/common"; +import { Body, Controller, Get, NotFoundException, Param, Post } from "@nestjs/common"; import { ApiTags } from "@nestjs/swagger"; import { SolversService } from "./solvers.service"; +import { RegisterSolverDto } from "./dto/register-solver.dto"; +import { verifyStellarSignature, buildRegisterMessage } from "../common/stellar-signature"; @ApiTags("solvers") @Controller("api/v1/solvers") export class SolversController { constructor(private readonly solversService: SolversService) {} + @Post("register") + register(@Body() dto: RegisterSolverDto) { + // Prove the caller controls the claimed solver address before registering. + verifyStellarSignature(dto.address, buildRegisterMessage(dto.address), dto.signature); + + const solver = this.solversService.register({ + address: dto.address, + name: dto.name, + bondAmount: dto.bondAmount, + isActive: dto.isActive ?? false, + avgFillTime: 0, + supportedChains: dto.supportedChains, + supportedTokens: dto.supportedTokens, + }); + return solver; + } + @Get() getLeaderboard() { const solvers = [...this.solversService.getAll()].sort( diff --git a/src/solvers/solvers.seed.ts b/src/solvers/solvers.seed.ts index b6cc64d..b97f684 100644 --- a/src/solvers/solvers.seed.ts +++ b/src/solvers/solvers.seed.ts @@ -1,11 +1,30 @@ +import { Keypair } from "@stellar/stellar-sdk"; import { SolverRecord } from "./solvers.types"; +/** + * Well-known test keypairs for seeded solvers. + * Secret keys are only used in tests / the solver-bot demo. + * In production solvers register via POST /api/v1/solvers/register. + */ +export const SEED_SOLVER_SECRETS = { + ALPHA: "SCWJJ7RJRPSCSLIJ2FUPEE5MSKKL7TIBK65EXB7NDFC5MGBN6IPOU7PF", + BETA: "SBWYIL4TL74OJO3AY2C7C6HACHBDQUURZAY5H2URSARNNJ2OHKO6BQ7A", + GAMMA: "SABOQPQHOWLQD27MQ5EHT2B2HCEFOUN2QONXBUZWWKPOMVQYBZVRASCM", +} as const; + +/** Keypairs derived from the secrets above. Used by tests and the solver-bot demo. */ +export const SEED_SOLVER_KEYPAIRS = { + ALPHA: Keypair.fromSecret(SEED_SOLVER_SECRETS.ALPHA), + BETA: Keypair.fromSecret(SEED_SOLVER_SECRETS.BETA), + GAMMA: Keypair.fromSecret(SEED_SOLVER_SECRETS.GAMMA), +}; + export function buildSeedSolvers(): SolverRecord[] { const now = Math.floor(Date.now() / 1000); return [ { - address: "SOLVER_ALPHA", + address: SEED_SOLVER_KEYPAIRS.ALPHA.publicKey(), name: "Alpha Market Making", bondAmount: "50000000000", // 5000 USDC fillsCompleted: 842, @@ -18,7 +37,7 @@ export function buildSeedSolvers(): SolverRecord[] { supportedTokens: ["USDC", "WETH", "WBTC"], }, { - address: "SOLVER_BETA", + address: SEED_SOLVER_KEYPAIRS.BETA.publicKey(), name: "Beta Liquidity Co", bondAmount: "100000000000", // 10000 USDC fillsCompleted: 1241, @@ -31,7 +50,7 @@ export function buildSeedSolvers(): SolverRecord[] { supportedTokens: ["USDC", "WETH", "WBTC", "MATIC", "AVAX"], }, { - address: "SOLVER_GAMMA", + address: SEED_SOLVER_KEYPAIRS.GAMMA.publicKey(), name: "Gamma Arb Labs", bondAmount: "25000000000", // 2500 USDC fillsCompleted: 187, diff --git a/test/cors.e2e-spec.ts b/test/cors.e2e-spec.ts new file mode 100644 index 0000000..41ebd2f --- /dev/null +++ b/test/cors.e2e-spec.ts @@ -0,0 +1,88 @@ +/** + * e2e test: CORS_ORIGIN config is wired into the app. + * + * Verifies that Access-Control-Allow-Origin reflects the CORS_ORIGIN env var + * rather than being absent (NestJS default) or hard-coded. + */ +import { INestApplication, ValidationPipe } from "@nestjs/common"; +import { Test } from "@nestjs/testing"; +import { WsAdapter } from "@nestjs/platform-ws"; +import { ConfigService } from "@nestjs/config"; +import request from "supertest"; +import { AppModule } from "../src/app.module"; +import { AppConfig } from "../src/config/configuration"; +import { HttpExceptionFilter } from "../src/common/http-exception.filter"; + +async function createAppWithOrigin(origin: string): Promise { + // Override CORS_ORIGIN before the module initializes. + process.env.CORS_ORIGIN = origin; + + const moduleRef = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + const app = moduleRef.createNestApplication(); + app.useWebSocketAdapter(new WsAdapter(app)); + app.useGlobalFilters(new HttpExceptionFilter()); + app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); + + const configService = app.get(ConfigService); + const corsOrigin = configService.get("corsOrigin", { infer: true }); + app.enableCors({ origin: corsOrigin }); + + await app.init(); + return app; +} + +describe("CORS (e2e)", () => { + afterEach(() => { + // Restore so other tests are not affected. + delete process.env.CORS_ORIGIN; + }); + + it("responds with Access-Control-Allow-Origin: * when CORS_ORIGIN is *", async () => { + const app = await createAppWithOrigin("*"); + try { + const res = await request(app.getHttpServer()) + .get("/health") + .set("Origin", "http://example.com") + .expect(200); + + expect(res.headers["access-control-allow-origin"]).toBe("*"); + } finally { + await app.close(); + } + }); + + it("reflects a specific CORS_ORIGIN in the response header", async () => { + const allowedOrigin = "https://app.vortex.finance"; + const app = await createAppWithOrigin(allowedOrigin); + try { + const res = await request(app.getHttpServer()) + .get("/health") + .set("Origin", allowedOrigin) + .expect(200); + + expect(res.headers["access-control-allow-origin"]).toBe(allowedOrigin); + } finally { + await app.close(); + } + }); + + it("does NOT echo back an origin that is not in CORS_ORIGIN", async () => { + const app = await createAppWithOrigin("https://app.vortex.finance"); + try { + const res = await request(app.getHttpServer()) + .get("/health") + .set("Origin", "https://evil.example.com") + .expect(200); + + // When origin is a specific string and the request Origin doesn't match, + // express-cors either omits the header or sets it to the allowed origin. + // Either way it must NOT be the attacker's origin. + expect(res.headers["access-control-allow-origin"]).not.toBe("https://evil.example.com"); + } finally { + await app.close(); + } + }); +}); diff --git a/test/intents.e2e-spec.ts b/test/intents.e2e-spec.ts index 00c272b..4918eb7 100644 --- a/test/intents.e2e-spec.ts +++ b/test/intents.e2e-spec.ts @@ -1,9 +1,37 @@ import { INestApplication } from "@nestjs/common"; import request from "supertest"; +import { Keypair } from "@stellar/stellar-sdk"; import { createTestApp } from "./utils/create-test-app"; +import { + SEED_SOLVER_KEYPAIRS, + SEED_SOLVER_SECRETS, +} from "../src/solvers/solvers.seed"; +import { + buildAcceptMessage, + buildFillMessage, + buildCancelMessage, +} from "../src/common/stellar-signature"; + +// --------------------------------------------------------------------------- +// Test keypairs +// --------------------------------------------------------------------------- + +/** A Stellar keypair used as the intent user in tests that require signing. */ +const USER_KP = Keypair.fromSecret("SDBC33G6FPFXVISLHM2WXR25WYRKLCQFXBELH2RWBV3ZB3ZSO54AQR4S"); + +const ALPHA_KP = SEED_SOLVER_KEYPAIRS.ALPHA; +const BETA_KP = SEED_SOLVER_KEYPAIRS.BETA; + +function sign(kp: Keypair, message: string): string { + return kp.sign(Buffer.from(message, "utf8")).toString("base64"); +} + +// --------------------------------------------------------------------------- +// Test data +// --------------------------------------------------------------------------- const validCreateBody = { - user: "GE2ETESTUSER1234567", + user: USER_KP.publicKey(), srcChain: "ethereum", srcTokenAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", srcTokenSymbol: "USDC", @@ -68,28 +96,34 @@ describe("IntentsController (e2e)", () => { const created = await createIntent(); expect(created.state).toBe("open"); + // Accept with valid ALPHA solver signature + const acceptSig = sign(ALPHA_KP, buildAcceptMessage(created.intentId, ALPHA_KP.publicKey())); const accepted = await request(app.getHttpServer()) .post(`/api/v1/intents/${created.intentId}/accept`) - .send({ solver: "SOLVER_ALPHA" }) + .send({ solver: ALPHA_KP.publicKey(), signature: acceptSig }) .expect(201); expect(accepted.body.state).toBe("accepted"); - expect(accepted.body.solver).toBe("SOLVER_ALPHA"); + expect(accepted.body.solver).toBe(ALPHA_KP.publicKey()); // double-accept on a non-open intent must conflict + const betaAcceptSig = sign(BETA_KP, buildAcceptMessage(created.intentId, BETA_KP.publicKey())); await request(app.getHttpServer()) .post(`/api/v1/intents/${created.intentId}/accept`) - .send({ solver: "SOLVER_BETA" }) + .send({ solver: BETA_KP.publicKey(), signature: betaAcceptSig }) .expect(409); - // wrong solver filling must be forbidden + // wrong solver filling must be forbidden (address mismatch, before sig check) + const betaFillSig = sign(BETA_KP, buildFillMessage(created.intentId, BETA_KP.publicKey())); await request(app.getHttpServer()) .post(`/api/v1/intents/${created.intentId}/fill`) - .send({ solver: "SOLVER_BETA", fillAmount: "995000" }) + .send({ solver: BETA_KP.publicKey(), fillAmount: "995000", signature: betaFillSig }) .expect(403); + // correct solver fills + const fillSig = sign(ALPHA_KP, buildFillMessage(created.intentId, ALPHA_KP.publicKey())); const filled = await request(app.getHttpServer()) .post(`/api/v1/intents/${created.intentId}/fill`) - .send({ solver: "SOLVER_ALPHA", fillAmount: "995000", txHash: "e2e-hash" }) + .send({ solver: ALPHA_KP.publicKey(), fillAmount: "995000", txHash: "e2e-hash", signature: fillSig }) .expect(201); expect(filled.body.state).toBe("filled"); expect(filled.body.fillAmount).toBe("995000"); @@ -97,15 +131,17 @@ describe("IntentsController (e2e)", () => { }); it("fill amount below minimum returns the original custom error shape", async () => { - const created = await createIntent({ user: "GBELOWMINIMUM12345" }); + const created = await createIntent(); + const acceptSig = sign(ALPHA_KP, buildAcceptMessage(created.intentId, ALPHA_KP.publicKey())); await request(app.getHttpServer()) .post(`/api/v1/intents/${created.intentId}/accept`) - .send({ solver: "SOLVER_ALPHA" }) + .send({ solver: ALPHA_KP.publicKey(), signature: acceptSig }) .expect(201); + const fillSig = sign(ALPHA_KP, buildFillMessage(created.intentId, ALPHA_KP.publicKey())); const res = await request(app.getHttpServer()) .post(`/api/v1/intents/${created.intentId}/fill`) - .send({ solver: "SOLVER_ALPHA", fillAmount: "1" }) + .send({ solver: ALPHA_KP.publicKey(), fillAmount: "1", signature: fillSig }) .expect(400); expect(res.body).toEqual({ error: "Fill amount below minimum", @@ -115,35 +151,51 @@ describe("IntentsController (e2e)", () => { }); it("accept with an unknown/inactive solver is forbidden", async () => { - const created = await createIntent({ user: "GUNKNOWNSOLVER12345" }); + const created = await createIntent(); + // Use a valid keypair that is NOT registered as a solver + const unknownKp = Keypair.fromSecret("SBEEB2ZY2D25GRU4TXUARHHPQ2ASDRVQJZXWBUMW27VBVT3FCU2MEU5Q"); + const sig = sign(unknownKp, buildAcceptMessage(created.intentId, unknownKp.publicKey())); await request(app.getHttpServer()) .post(`/api/v1/intents/${created.intentId}/accept`) - .send({ solver: "NOT_A_REAL_SOLVER" }) + .send({ solver: unknownKp.publicKey(), signature: sig }) .expect(403); }); - it("cancel by the wrong user is forbidden, by the right user succeeds", async () => { - const created = await createIntent({ user: "GCANCELOWNER123456" }); + it("cancel: invalid signature returns 401, wrong user returns 403, correct user+sig succeeds", async () => { + const created = await createIntent(); + // Wrong user address (different keypair) - forbidden before sig check + const wrongKp = Keypair.fromSecret("SBEEB2ZY2D25GRU4TXUARHHPQ2ASDRVQJZXWBUMW27VBVT3FCU2MEU5Q"); + const wrongSig = sign(wrongKp, buildCancelMessage(created.intentId)); await request(app.getHttpServer()) .post(`/api/v1/intents/${created.intentId}/cancel`) - .send({ user: "GSOMEONEELSE1234567" }) + .send({ user: wrongKp.publicKey(), signature: wrongSig }) .expect(403); + // Correct user but invalid signature (tampered) + await request(app.getHttpServer()) + .post(`/api/v1/intents/${created.intentId}/cancel`) + .send({ user: USER_KP.publicKey(), signature: "aW52YWxpZHNpZ25hdHVyZXBhZGRpbmc=" }) + .expect(401); + + // Correct user + valid signature + const validSig = sign(USER_KP, buildCancelMessage(created.intentId)); const cancelled = await request(app.getHttpServer()) .post(`/api/v1/intents/${created.intentId}/cancel`) - .send({ user: "GCANCELOWNER123456" }) + .send({ user: USER_KP.publicKey(), signature: validSig }) .expect(201); expect(cancelled.body.state).toBe("cancelled"); }); it("GET /api/v1/intents/user/:address reflects created intents", async () => { - await createIntent({ user: "GUSERLOOKUP12345678" }); + await createIntent(); const res = await request(app.getHttpServer()) - .get("/api/v1/intents/user/GUSERLOOKUP12345678") + .get(`/api/v1/intents/user/${USER_KP.publicKey()}`) .expect(200); expect(res.body.count).toBeGreaterThanOrEqual(1); - expect(res.body.intents.every((i: { user: string }) => i.user === "GUSERLOOKUP12345678")).toBe(true); + expect( + res.body.intents.every((i: { user: string }) => i.user === USER_KP.publicKey()), + ).toBe(true); }); it("POST /api/v1/intents/quote returns quotes sorted by dstAmount desc", async () => { diff --git a/test/solvers.e2e-spec.ts b/test/solvers.e2e-spec.ts index 5e78d0d..3fd3a19 100644 --- a/test/solvers.e2e-spec.ts +++ b/test/solvers.e2e-spec.ts @@ -1,6 +1,13 @@ import { INestApplication } from "@nestjs/common"; import request from "supertest"; +import { Keypair } from "@stellar/stellar-sdk"; import { createTestApp } from "./utils/create-test-app"; +import { SEED_SOLVER_KEYPAIRS } from "../src/solvers/solvers.seed"; +import { buildRegisterMessage } from "../src/common/stellar-signature"; + +const ALPHA_ADDR = SEED_SOLVER_KEYPAIRS.ALPHA.publicKey(); +const BETA_ADDR = SEED_SOLVER_KEYPAIRS.BETA.publicKey(); +const GAMMA_ADDR = SEED_SOLVER_KEYPAIRS.GAMMA.publicKey(); describe("SolversController (e2e)", () => { let app: INestApplication; @@ -17,11 +24,11 @@ describe("SolversController (e2e)", () => { const res = await request(app.getHttpServer()).get("/api/v1/solvers").expect(200); expect(res.body.count).toBe(3); const counts = res.body.solvers.map((s: { fillsCompleted: number }) => s.fillsCompleted); - expect(counts).toEqual([...counts].sort((a, b) => b - a)); + expect(counts).toEqual([...counts].sort((a: number, b: number) => b - a)); }); it("GET /api/v1/solvers/:address returns the solver record", async () => { - const res = await request(app.getHttpServer()).get("/api/v1/solvers/SOLVER_BETA").expect(200); + const res = await request(app.getHttpServer()).get(`/api/v1/solvers/${BETA_ADDR}`).expect(200); expect(res.body.name).toBe("Beta Liquidity Co"); }); @@ -32,15 +39,51 @@ describe("SolversController (e2e)", () => { it("GET /api/v1/solvers/:address/stats returns the computed success rate", async () => { const res = await request(app.getHttpServer()) - .get("/api/v1/solvers/SOLVER_GAMMA/stats") + .get(`/api/v1/solvers/${GAMMA_ADDR}/stats`) .expect(200); expect(res.body.successRate).toBeCloseTo(187 / (187 + 12), 4); }); it("GET /api/v1/solvers/:address/stats 404s for an unknown address", async () => { - const res = await request(app.getHttpServer()) - .get("/api/v1/solvers/NOPE/stats") - .expect(404); + const res = await request(app.getHttpServer()).get("/api/v1/solvers/NOPE/stats").expect(404); expect(res.body).toEqual({ error: "Solver not found" }); }); + + it("POST /api/v1/solvers/register requires a valid signature", async () => { + const newKp = Keypair.random(); + const address = newKp.publicKey(); + + // Tampered signature must be rejected + await request(app.getHttpServer()) + .post("/api/v1/solvers/register") + .send({ + address, + name: "Test Solver", + bondAmount: "10000000000", + isActive: false, + supportedChains: ["ethereum"], + supportedTokens: ["USDC"], + signature: "aW52YWxpZHNpZ25hdHVyZXBhZGRpbmc=", + }) + .expect(401); + + // Valid signature succeeds + const sig = newKp.sign(Buffer.from(buildRegisterMessage(address), "utf8")).toString("base64"); + const res = await request(app.getHttpServer()) + .post("/api/v1/solvers/register") + .send({ + address, + name: "Test Solver", + bondAmount: "10000000000", + isActive: false, + supportedChains: ["ethereum"], + supportedTokens: ["USDC"], + signature: sig, + }) + .expect(201); + + expect(res.body.address).toBe(address); + expect(res.body.name).toBe("Test Solver"); + expect(res.body.isActive).toBe(false); + }); }); diff --git a/test/stats.e2e-spec.ts b/test/stats.e2e-spec.ts index d6d9ea3..36a9250 100644 --- a/test/stats.e2e-spec.ts +++ b/test/stats.e2e-spec.ts @@ -1,6 +1,15 @@ import { INestApplication } from "@nestjs/common"; import request from "supertest"; import { createTestApp } from "./utils/create-test-app"; +import { SEED_SOLVER_KEYPAIRS } from "../src/solvers/solvers.seed"; +import { buildAcceptMessage, buildFillMessage } from "../src/common/stellar-signature"; +import { Keypair } from "@stellar/stellar-sdk"; + +const ALPHA_KP = SEED_SOLVER_KEYPAIRS.ALPHA; + +function sign(kp: Keypair, message: string): string { + return kp.sign(Buffer.from(message, "utf8")).toString("base64"); +} describe("StatsController (e2e)", () => { let app: INestApplication; @@ -39,15 +48,18 @@ describe("StatsController (e2e)", () => { minDstAmount: "990000", }) .expect(201); - const intentId = createRes.body.intentId; + const intentId = createRes.body.intentId as string; + const acceptSig = sign(ALPHA_KP, buildAcceptMessage(intentId, ALPHA_KP.publicKey())); await request(app.getHttpServer()) .post(`/api/v1/intents/${intentId}/accept`) - .send({ solver: "SOLVER_ALPHA" }) + .send({ solver: ALPHA_KP.publicKey(), signature: acceptSig }) .expect(201); + + const fillSig = sign(ALPHA_KP, buildFillMessage(intentId, ALPHA_KP.publicKey())); await request(app.getHttpServer()) .post(`/api/v1/intents/${intentId}/fill`) - .send({ solver: "SOLVER_ALPHA", fillAmount: "995000" }) + .send({ solver: ALPHA_KP.publicKey(), fillAmount: "995000", signature: fillSig }) .expect(201); const after = await request(app.getHttpServer()).get("/api/v1/stats").expect(200); diff --git a/test/utils/create-test-app.ts b/test/utils/create-test-app.ts index d49d0b5..945d042 100644 --- a/test/utils/create-test-app.ts +++ b/test/utils/create-test-app.ts @@ -1,7 +1,9 @@ import { INestApplication, ValidationPipe } from "@nestjs/common"; import { Test } from "@nestjs/testing"; import { WsAdapter } from "@nestjs/platform-ws"; +import { ConfigService } from "@nestjs/config"; import { AppModule } from "../../src/app.module"; +import { AppConfig } from "../../src/config/configuration"; import { HttpExceptionFilter } from "../../src/common/http-exception.filter"; export async function createTestApp(): Promise { @@ -13,6 +15,12 @@ export async function createTestApp(): Promise { app.useWebSocketAdapter(new WsAdapter(app)); app.useGlobalFilters(new HttpExceptionFilter()); app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); + + // Wire CORS the same way main.ts does so the e2e environment is faithful. + const configService = app.get(ConfigService); + const corsOrigin = configService.get("corsOrigin", { infer: true }); + app.enableCors({ origin: corsOrigin }); + await app.init(); return app; }