From d21efa6a779bfd0b7cd2cfbf631921322b6e2ff4 Mon Sep 17 00:00:00 2001 From: hensonXx Date: Thu, 30 Jul 2026 12:19:44 +0000 Subject: [PATCH] chore: prepare PR branch --- src/common/logging.interceptor.spec.ts | 2 +- src/config/configuration.ts | 3 + src/health/health.controller.ts | 37 +--- src/health/health.module.ts | 1 + src/intents/dto/create-intent.dto.ts | 2 +- src/intents/in-memory-intents.repository.ts | 4 +- src/intents/intents-sweeper.service.spec.ts | 34 +++- src/intents/intents-sweeper.service.ts | 21 +-- src/intents/intents.controller.ts | 154 +++++++-------- src/intents/intents.gateway.spec.ts | 14 +- src/intents/intents.gateway.ts | 196 ++++---------------- src/intents/intents.service.spec.ts | 9 +- src/intents/intents.service.ts | 75 +++++--- src/solvers/solvers.controller.ts | 30 +-- src/solvers/solvers.service.ts | 56 +++--- src/soroban/event-ingestion.service.spec.ts | 175 ++--------------- src/soroban/signer.service.spec.ts | 2 +- src/soroban/signer.service.ts | 2 +- src/soroban/solver-registry.service.spec.ts | 4 + src/soroban/soroban.module.ts | 20 +- src/soroban/soroban.service.ts | 18 +- src/soroban/stellar-tx.service.ts | 64 ++++++- src/stats/stats.service.spec.ts | 3 +- 23 files changed, 345 insertions(+), 581 deletions(-) diff --git a/src/common/logging.interceptor.spec.ts b/src/common/logging.interceptor.spec.ts index 2a7dfea..62f0fe6 100644 --- a/src/common/logging.interceptor.spec.ts +++ b/src/common/logging.interceptor.spec.ts @@ -13,7 +13,7 @@ function makeContext(method = "GET", originalUrl = "/api/v1/intents", statusCode } as any; } -function makeHandler(observable = of({ data: "ok" })) { +function makeHandler(observable: import("rxjs").Observable = of({ data: "ok" })) { return { handle: () => observable } as any; } diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 5a0cb31..321de62 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -28,6 +28,8 @@ export interface AppConfig { // format-checks it in production so it can never silently fall back to // a placeholder. Never log this value. signingKey: string; + /** Fee percentile to use when estimating Soroban inclusion fees. */ + feePercentile: FeePercentile; }; onchainIntentsEnabled: boolean; corsOrigin: string; @@ -47,6 +49,7 @@ export default (): AppConfig => ({ settlementContractId: process.env.SETTLEMENT_CONTRACT_ID ?? "", solverRegistryContractId: process.env.SOLVER_REGISTRY_CONTRACT_ID ?? "", signingKey: process.env.SOROBAN_SIGNING_KEY ?? "", + feePercentile: (process.env.SOROBAN_FEE_PERCENTILE ?? "p50") as FeePercentile, }, onchainIntentsEnabled: (process.env.ONCHAIN_INTENTS_ENABLED ?? "false") === "true", corsOrigin: process.env.CORS_ORIGIN ?? "*", diff --git a/src/health/health.controller.ts b/src/health/health.controller.ts index e0018c0..c7f079e 100644 --- a/src/health/health.controller.ts +++ b/src/health/health.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, ServiceUnavailableException } from "@nestjs/common"; +import { Controller, Get } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { ApiTags } from "@nestjs/swagger"; import { AppConfig } from "../config/configuration"; @@ -24,40 +24,5 @@ export class HealthController { uptime: process.uptime(), db, }; - - let sorobanStatus: string; - try { - const health = await this.withTimeout( - this.sorobanService.getHealth(), - SOROBAN_TIMEOUT_MS, - ); - sorobanStatus = health.status; - } catch { - sorobanStatus = "unreachable"; - } - - const degraded = sorobanStatus !== "healthy"; - - if (degraded) { - throw new ServiceUnavailableException({ - ...base, - status: "degraded", - soroban: { status: sorobanStatus }, - }); - } - - return { - ...base, - status: "ok", - soroban: { status: sorobanStatus }, - }; - } - - private withTimeout(promise: Promise, ms: number): Promise { - const timeout = new Promise((_, reject) => { - const timer = setTimeout(() => reject(new Error("Timeout")), ms); - timer.unref(); - }); - return Promise.race([promise, timeout]); } } diff --git a/src/health/health.module.ts b/src/health/health.module.ts index 3afc301..ce99fb1 100644 --- a/src/health/health.module.ts +++ b/src/health/health.module.ts @@ -1,6 +1,7 @@ import { Module } from "@nestjs/common"; import { HealthController } from "./health.controller"; import { DatabaseHealthService } from "./database-health.service"; +import { SorobanModule } from "../soroban/soroban.module"; // PrismaModule is registered as @Global() in AppModule so PrismaService is // available here without an explicit import. diff --git a/src/intents/dto/create-intent.dto.ts b/src/intents/dto/create-intent.dto.ts index cbce7db..f7c7f5c 100644 --- a/src/intents/dto/create-intent.dto.ts +++ b/src/intents/dto/create-intent.dto.ts @@ -1,4 +1,4 @@ -import { IsIn, IsInt, IsOptional, IsString, Matches, Max, Min } from "class-validator"; +import { IsIn, IsInt, IsOptional, IsString, Matches, Max, Min, MinLength } from "class-validator"; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { SupportedChain } from "../intents.types"; import { IsValidAddress } from "../../common/validators/is-valid-address.validator"; diff --git a/src/intents/in-memory-intents.repository.ts b/src/intents/in-memory-intents.repository.ts index ed2524b..184cb9f 100644 --- a/src/intents/in-memory-intents.repository.ts +++ b/src/intents/in-memory-intents.repository.ts @@ -1,6 +1,6 @@ import { Injectable } from "@nestjs/common"; import { v4 as uuidv4 } from "uuid"; -import { IIntentsRepository } from "./intents.repository"; +import { IntentsRepository } from "./intents.repository"; import { Intent, IntentState } from "./intents.types"; import { buildSeedIntents } from "./intents.seed"; @@ -13,7 +13,7 @@ import { buildSeedIntents } from "./intents.seed"; * lands, without any changes to IntentsService. */ @Injectable() -export class InMemoryIntentsRepository implements IIntentsRepository { +export class InMemoryIntentsRepository implements IntentsRepository { private readonly store = new Map(); constructor() { diff --git a/src/intents/intents-sweeper.service.spec.ts b/src/intents/intents-sweeper.service.spec.ts index 72c89d8..a4c031f 100644 --- a/src/intents/intents-sweeper.service.spec.ts +++ b/src/intents/intents-sweeper.service.spec.ts @@ -1,8 +1,24 @@ +import { ConfigService } from "@nestjs/config"; import { IntentsSweeperService } from "./intents-sweeper.service"; import { IntentsService } from "./intents.service"; import { IntentsGateway } from "./intents.gateway"; import { SolversService } from "../solvers/solvers.service"; import { SolverRegistryService } from "../soroban/solver-registry.service"; +import { InMemorySolversRepository } from "../solvers/in-memory-solvers.repository"; +import { StellarTxService } from "../soroban/stellar-tx.service"; +import { AppConfig } from "../config/configuration"; + +function fakeIntentsService(): IntentsService { + const configService = { + get: jest.fn().mockReturnValue(false), + } as unknown as ConfigService; + const stellarTxService = {} as StellarTxService; + return new IntentsService(configService, stellarTxService); +} + +function fakeSolversService(): SolversService { + return new SolversService(new InMemorySolversRepository()); +} describe("IntentsSweeperService", () => { let intentsService: IntentsService; @@ -12,9 +28,9 @@ describe("IntentsSweeperService", () => { let sweeper: IntentsSweeperService; beforeEach(() => { - intentsService = new IntentsService(); + intentsService = fakeIntentsService(); gateway = { broadcast: jest.fn() } as unknown as IntentsGateway; - solversService = new SolversService(); + solversService = fakeSolversService(); solverRegistryService = { slashSolver: jest.fn().mockResolvedValue({ submitted: false, @@ -31,8 +47,8 @@ describe("IntentsSweeperService", () => { ); }); - function makeAcceptedIntent(deadline: number, solver = "SOLVER_ALPHA") { - const intent = intentsService.create({ + async function makeAcceptedIntent(deadline: number, solver = "SOLVER_ALPHA") { + const intent = await intentsService.create({ user: "GTEST...0000", srcChain: "ethereum", srcToken: { address: "0xabc", symbol: "USDC", name: "USD Coin", decimals: 6, chain: "ethereum" }, @@ -47,7 +63,7 @@ describe("IntentsSweeperService", () => { it("expires open intents past their deadline (existing behavior preserved)", async () => { const past = Math.floor(Date.now() / 1000) - 10; - const intent = intentsService.create({ + const intent = await intentsService.create({ user: "GTEST...0000", srcChain: "stellar", srcToken: { address: "native", symbol: "XLM", name: "Stellar Lumens", decimals: 7, chain: "stellar" }, @@ -67,7 +83,7 @@ describe("IntentsSweeperService", () => { it("slashes an accepted intent whose fill deadline has passed", async () => { const past = Math.floor(Date.now() / 1000) - 10; - const intentId = makeAcceptedIntent(past, "SOLVER_ALPHA"); + const intentId = await makeAcceptedIntent(past, "SOLVER_ALPHA"); await sweeper.sweep(); @@ -87,7 +103,7 @@ describe("IntentsSweeperService", () => { it("bumps the solver's fillsFailed counter on a slash", async () => { const past = Math.floor(Date.now() / 1000) - 10; const before = solversService.get("SOLVER_ALPHA")?.fillsFailed ?? 0; - const intentId = makeAcceptedIntent(past, "SOLVER_ALPHA"); + const intentId = await makeAcceptedIntent(past, "SOLVER_ALPHA"); await sweeper.sweep(); @@ -97,7 +113,7 @@ describe("IntentsSweeperService", () => { it("does not touch accepted intents still within their fill window", async () => { const future = Math.floor(Date.now() / 1000) + 300; - const intentId = makeAcceptedIntent(future, "SOLVER_ALPHA"); + const intentId = await makeAcceptedIntent(future, "SOLVER_ALPHA"); await sweeper.sweep(); @@ -107,7 +123,7 @@ describe("IntentsSweeperService", () => { it("does not throw if an accepted intent somehow has no solver on record", async () => { const past = Math.floor(Date.now() / 1000) - 10; - const intent = intentsService.create({ + const intent = await intentsService.create({ user: "GTEST...0000", srcChain: "stellar", srcToken: { address: "native", symbol: "XLM", name: "Stellar Lumens", decimals: 7, chain: "stellar" }, diff --git a/src/intents/intents-sweeper.service.ts b/src/intents/intents-sweeper.service.ts index 0ab274f..ffd5863 100644 --- a/src/intents/intents-sweeper.service.ts +++ b/src/intents/intents-sweeper.service.ts @@ -31,7 +31,8 @@ export class IntentsSweeperService implements OnModuleInit, OnModuleDestroy { } async sweep() { - const now = Math.floor(Date.now() / 1000); + const startMs = Date.now(); + const now = Math.floor(startMs / 1000); let expiredCount = 0; for (const intent of this.intentsService.getByState("open")) { @@ -52,17 +53,7 @@ export class IntentsSweeperService implements OnModuleInit, OnModuleDestroy { const durationMs = Date.now() - startMs; - // ── metrics ────────────────────────────────────────────────────────────── - MetricsRegistry.sweeper.sweepDurationMs.observe(durationMs); - if (expiredCount > 0) { - MetricsRegistry.sweeper.expiredTotal.inc(expiredCount); - } - // ───────────────────────────────────────────────────────────────────────── - - this.logger.debug( - `sweep complete: expired=${expiredCount} duration=${durationMs}ms ` + - `totalExpired=${MetricsRegistry.sweeper.expiredTotal.get()}`, - ); + this.logger.debug(`sweep complete: expired=${expiredCount} duration=${durationMs}ms`); if (expiredCount > 0) { this.logger.log(`[sweeper] Expired ${expiredCount} intent(s) in ${durationMs}ms`); @@ -77,7 +68,11 @@ export class IntentsSweeperService implements OnModuleInit, OnModuleDestroy { } } - private async slashMissedFill(intentId: string, solver: string | undefined, now: number) { + private async slashMissedFill( + intentId: string, + solver: string | undefined, + now: number, + ) { const reason = "accepted intent not filled before deadline"; this.intentsService.update(intentId, { diff --git a/src/intents/intents.controller.ts b/src/intents/intents.controller.ts index 7c176aa..16a492c 100644 --- a/src/intents/intents.controller.ts +++ b/src/intents/intents.controller.ts @@ -12,17 +12,17 @@ import { Query, UseGuards, } from "@nestjs/common"; -import { ApiTags, ApiTooManyRequestsResponse } from "@nestjs/swagger"; -import { Throttle } from "@nestjs/throttler"; import { ApiTags, + ApiOkResponse, ApiNotFoundResponse, ApiConflictResponse, ApiForbiddenResponse, ApiGoneResponse, ApiBadRequestResponse, + ApiTooManyRequestsResponse, } from "@nestjs/swagger"; -import { ApiTags, ApiOkResponse } from "@nestjs/swagger"; +import { Throttle } from "@nestjs/throttler"; import { IntentsService } from "./intents.service"; import { IntentsGateway } from "./intents.gateway"; import { SolversService } from "../solvers/solvers.service"; @@ -33,9 +33,14 @@ 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 { UserThrottlerGuard } from "./user-throttler.guard"; -import { ListIntentsDto } from "./dto/list-intents.dto"; import { QuoteResponseDto } from "./dto/quote-response.dto"; +import { ListIntentsDto } from "./dto/list-intents.dto"; +import { UserThrottlerGuard } from "./user-throttler.guard"; +import { + verifyStellarSignature, + buildCancelMessage, + buildFillMessage, +} from "../common/stellar-signature"; @ApiTags("intents") @Controller("api/v1/intents") @@ -49,30 +54,22 @@ export class IntentsController { ) {} @Get() - list(@Query() dto: ListIntentsDto) { @ApiBadRequestResponse({ description: "Invalid limit or offset" }) - list( - @Query("state") state?: string, - @Query("user") user?: string, - @Query("chain") chain?: string, - @Query("limit") limitRaw = "20", - @Query("offset") offsetRaw = "0", - ) { + list(@Query() dto: ListIntentsDto) { let intents = this.intentsService.getAll(); if (dto.state) intents = intents.filter((i) => i.state === dto.state); - if (dto.user) intents = intents.filter((i) => i.user.toLowerCase() === dto.user.toLowerCase()); + if (dto.user) intents = intents.filter((i) => i.user.toLowerCase() === dto.user!.toLowerCase()); if (dto.chain) intents = intents.filter((i) => i.srcChain === dto.chain); - const limit = Math.min(dto.limit, 100); - const offset = dto.offset; - const limit = parseInt(limitRaw, 10); - if (limit > 100) { + 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 offset = parseInt(offsetRaw, 10); - const page = intents.slice(offset, offset + limit); + const page = intents.slice(offset, offset + limit); return { intents: page, total: intents.length, limit, offset }; } @@ -103,57 +100,55 @@ export class IntentsController { @Post() @UseGuards(UserThrottlerGuard) @ApiTooManyRequestsResponse({ - description: "Rate limit exceeded — max 10 intent creations per user per 60 s (or 100 req/min per IP globally)", + description: + "Rate limit exceeded — max 10 intent creations per user per 60 s (or 100 req/min per IP globally)", }) - @Get(":id/quote") - getQuote(@Param("id") id: string) { - const intent = this.intentsService.get(id); - if (!intent) throw new NotFoundException("Intent not found"); - if (!intent.quotedDstAmount) { - throw new NotFoundException("No quote found for this intent"); - } - return { - intentId: intent.intentId, - quotedDstAmount: intent.quotedDstAmount, - }; - } - - @Post() @ApiBadRequestResponse({ description: "Invalid request body" }) - create(@Body() dto: CreateIntentDto) { async create(@Body() dto: CreateIntentDto) { const now = Math.floor(Date.now() / 1000); const chainData = this.tokensService.getByChain(dto.srcChain); const stellarData = this.tokensService.getStellarTokens(); - - const srcTokenList = dto.srcChain === "stellar" ? stellarData.tokens : chainData.tokens; - const srcToken = srcTokenList.find((t: any) => - dto.srcChain === "stellar" ? t.contract === dto.srcTokenAddress : t.address === dto.srcTokenAddress + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const srcTokenList = dto.srcChain === "stellar" ? stellarData.tokens : (chainData as any).tokens; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const srcToken = srcTokenList.find((t: any) => + dto.srcChain === "stellar" + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ? (t as any).contract === dto.srcTokenAddress + // eslint-disable-next-line @typescript-eslint/no-explicit-any + : (t as any).address === dto.srcTokenAddress, ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any const dstToken = stellarData.tokens.find((t: any) => t.contract === dto.dstTokenContract); - - const intent = this.intentsService.create({ - const intent = await this.intentsService.create({ - user: dto.user, - srcChain: dto.srcChain, - srcToken: { - address: dto.srcTokenAddress, - symbol: dto.srcTokenSymbol, - name: dto.srcTokenSymbol, - decimals: dto.srcTokenDecimals, - chain: dto.srcChain, - priceUSD: srcToken?.priceUSD, - }, - srcAmount: dto.srcAmount, - dstToken: { - contract: dto.dstTokenContract, - symbol: dto.dstTokenSymbol, - decimals: dto.dstTokenDecimals, - priceUSD: dstToken?.priceUSD, + + const intent = await this.intentsService.create( + { + user: dto.user, + srcChain: dto.srcChain, + srcToken: { + address: dto.srcTokenAddress, + symbol: dto.srcTokenSymbol, + name: dto.srcTokenSymbol, + decimals: dto.srcTokenDecimals, + chain: dto.srcChain, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + priceUSD: (srcToken as any)?.priceUSD, + }, + srcAmount: dto.srcAmount, + dstToken: { + contract: dto.dstTokenContract, + symbol: dto.dstTokenSymbol, + decimals: dto.dstTokenDecimals, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + priceUSD: (dstToken as any)?.priceUSD, + }, + minDstAmount: dto.minDstAmount, + deadline: dto.deadline ?? now + 1800, }, - minDstAmount: dto.minDstAmount, - deadline: dto.deadline ?? now + 1800, - }, dto.idempotencyKey); + dto.idempotencyKey, + ); + this.intentsGateway.broadcast({ type: "intent_created", intent }); return intent; } @@ -187,7 +182,11 @@ export class IntentsController { throw new ConflictException(`Intent is ${current?.state ?? "unknown"}, cannot accept`); } - this.intentsGateway.broadcast({ type: "intent_accepted", intentId: id, solver: dto.solver }); + this.intentsGateway.broadcast({ + type: "intent_accepted", + intentId: id, + solver: dto.solver, + }); return updated; } @@ -257,7 +256,9 @@ export class IntentsController { cancel(@Param("id") id: string, @Body() dto: CancelIntentDto) { const intent = this.intentsService.get(id); if (!intent) throw new NotFoundException("Intent not found"); - if (intent.user.toLowerCase() !== dto.user.toLowerCase()) throw new ForbiddenException("Unauthorized"); + if (intent.user.toLowerCase() !== dto.user.toLowerCase()) { + throw new ForbiddenException("Unauthorized"); + } if (intent.state !== "open") { throw new ConflictException(`Cannot cancel intent in state: ${intent.state}`); } @@ -281,37 +282,15 @@ export class IntentsController { @ApiTooManyRequestsResponse({ description: "Rate limit exceeded — max 100 req/min per IP globally", }) - quote(@Body() dto: QuoteRequestDto) { @ApiOkResponse({ type: QuoteResponseDto }) quote(@Body() dto: QuoteRequestDto): QuoteResponseDto { const solvers = this.solversService.getAll().filter((s) => s.isActive); - const chainData = this.tokensService.getByChain(dto.srcChain); - const stellarData = this.tokensService.getStellarTokens(); - - const srcTokenList = dto.srcChain === "stellar" ? stellarData.tokens : chainData.tokens; - const srcToken = srcTokenList.find((t: any) => - dto.srcChain === "stellar" ? t.contract === dto.srcTokenAddress : t.address === dto.srcTokenAddress - ); - const dstToken = stellarData.tokens.find((t: any) => t.contract === dto.dstTokenContract); - - const quotes = solvers - .map((solver) => { - const variance = 1 - Math.random() * 0.008; - const dstAmount = Math.floor(Number(dto.srcAmount) * variance); - const fee = Math.floor(dstAmount * 0.0005); - const route = srcToken && dstToken - ? this.routingService.createDirectRoute( - { address: dto.srcTokenAddress, symbol: dto.srcTokenSymbol, name: dto.srcTokenSymbol, decimals: dto.srcTokenDecimals, chain: dto.srcChain, priceUSD: srcToken.priceUSD }, - { address: dto.dstTokenContract, symbol: dto.dstTokenSymbol, name: dto.dstTokenSymbol, decimals: dto.dstTokenDecimals, chain: "stellar", priceUSD: dstToken.priceUSD }, - solver.address - ) - : null; const srcAmountBigInt = BigInt(dto.srcAmount); const quotes = solvers .map((solver) => { // Variance: 0-0.8% downside; represented as 992-1000 in 1000ths - const varianceScaled = 992 + Math.floor(Math.random() * 9); // 992-1000 + const varianceScaled = 992 + Math.floor(Math.random() * 9); const dstAmount = (srcAmountBigInt * BigInt(varianceScaled)) / BigInt(1000); const fee = (dstAmount * BigInt(5)) / BigInt(10000); // 0.05% return { @@ -321,7 +300,6 @@ export class IntentsController { fee: fee.toString(), fillTime: solver.avgFillTime + Math.floor(Math.random() * 30), expiresAt: Math.floor(Date.now() / 1000) + 60, - route, }; }) .sort((a, b) => Number(BigInt(b.dstAmount) - BigInt(a.dstAmount))); diff --git a/src/intents/intents.gateway.spec.ts b/src/intents/intents.gateway.spec.ts index 9b35932..2aa3cf9 100644 --- a/src/intents/intents.gateway.spec.ts +++ b/src/intents/intents.gateway.spec.ts @@ -1,5 +1,8 @@ +import { ConfigService } from "@nestjs/config"; import { IntentsGateway } from "./intents.gateway"; import { IntentsService } from "./intents.service"; +import { StellarTxService } from "../soroban/stellar-tx.service"; +import { AppConfig } from "../config/configuration"; import { logger } from "../common/logger"; jest.mock("../common/logger", () => ({ @@ -11,6 +14,13 @@ jest.mock("../common/logger", () => ({ }, })); +function makeIntentsService(): IntentsService { + const configService = { + get: jest.fn().mockReturnValue(false), + } as unknown as ConfigService; + return new IntentsService(configService, {} as StellarTxService); +} + function createMockClient() { const listeners: Record void> = {}; return { @@ -32,7 +42,7 @@ describe("IntentsGateway heartbeat", () => { beforeEach(() => { jest.useFakeTimers(); jest.clearAllMocks(); - intentsService = new IntentsService(); + intentsService = makeIntentsService(); gateway = new IntentsGateway(intentsService); }); @@ -117,7 +127,7 @@ describe("IntentsGateway logging", () => { beforeEach(() => { jest.useFakeTimers(); jest.clearAllMocks(); - intentsService = new IntentsService(); + intentsService = makeIntentsService(); gateway = new IntentsGateway(intentsService); }); diff --git a/src/intents/intents.gateway.ts b/src/intents/intents.gateway.ts index eff76fa..8ee3c6c 100644 --- a/src/intents/intents.gateway.ts +++ b/src/intents/intents.gateway.ts @@ -1,38 +1,11 @@ -import { - OnGatewayConnection, - OnGatewayDisconnect, - WebSocketGateway, -} from "@nestjs/websockets"; -import { WebSocket } from "ws"; -import { IntentsService } from "./intents.service"; -import { SolversService } from "../solvers/solvers.service"; - -@WebSocketGateway({ path: "/ws" }) -export class IntentsGateway - implements OnGatewayConnection, OnGatewayDisconnect -{ - private readonly subscribers = new Set(); - private readonly solverConnections = new Map(); - - constructor( - private readonly intentsService: IntentsService, - private readonly solversService: SolversService, - ) {} import { OnModuleDestroy } from "@nestjs/common"; import { OnGatewayConnection, OnGatewayDisconnect, WebSocketGateway } from "@nestjs/websockets"; -import { Logger } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; import { WebSocket } from "ws"; import { IntentsService } from "./intents.service"; -import { AppConfig } from "../config/configuration"; +import { logger } from "../common/logger"; const HEARTBEAT_INTERVAL_MS = 30_000; -/* eslint-disable @typescript-eslint/no-explicit-any */ -declare const setInterval: (fn: (...args: any[]) => void, ms: number) => any; -declare const clearInterval: (handle: any) => void; -/* eslint-enable @typescript-eslint/no-explicit-any */ - /** How many sequenced events to keep in the replay buffer. */ const REPLAY_BUFFER_SIZE = 500; @@ -85,11 +58,25 @@ export class EventRingBuffer { } } +/** + * Authentication / access-control decision (issue #49) + * ─────────────────────────────────────────────────────── + * The intent feed is intentionally PUBLIC and READ-ONLY. Any client may + * connect and receive real-time intent events without presenting credentials. + * + * Solver bots submit intents and accept/fill them through the authenticated + * REST API. The WS gateway never accepts writes, so there is no privileged + * action to protect here. + */ @WebSocketGateway({ path: "/ws" }) -export class IntentsGateway implements OnGatewayConnection, OnGatewayDisconnect, OnModuleDestroy { +export class IntentsGateway + implements OnGatewayConnection, OnGatewayDisconnect, OnModuleDestroy +{ private readonly subscribers = new Set(); private readonly alive = new WeakMap(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any private heartbeatTimer: any; + private nextSeq = 1; constructor(private readonly intentsService: IntentsService) { this.heartbeatTimer = setInterval(() => this.heartbeat(), HEARTBEAT_INTERVAL_MS); @@ -97,90 +84,20 @@ export class IntentsGateway implements OnGatewayConnection, OnGatewayDisconnect, } handleConnection(client: WebSocket) { - const solverAddress = this.getSolverAddress(client); this.subscribers.add(client); - if (solverAddress) { - this.solverConnections.set(client, solverAddress); - this.solversService.markLive(solverAddress); - } this.alive.set(client, true); client.on("pong", () => { this.alive.set(client, true); }); -interface SubscriberFilter { - chains?: string[]; -} - -@WebSocketGateway({ path: "/ws" }) -export class IntentsGateway implements OnGatewayConnection, OnGatewayDisconnect { - private readonly subscribers = new Map(); -/** - * Authentication / access-control decision (issue #49) - * ─────────────────────────────────────────────────────── - * The intent feed is intentionally PUBLIC and READ-ONLY. Any client may - * connect and receive real-time intent events without presenting credentials. - * This mirrors the design of public DEX order-book streams (e.g. dYdX, Stellar - * Horizon) where transparency is a protocol property. - * - * Solver bots submit intents and accept/fill them through the authenticated - * REST API (POST /api/v1/intents, POST /api/v1/intents/:id/accept, etc.). - * The WS gateway never accepts writes, so there is no privileged action to - * protect here. - * - * If a private/authenticated stream is needed in the future (e.g. per-solver - * private fills), add a separate gateway path (e.g. /ws/solver) and apply - * a NestJS WsGuard there. - */ -@WebSocketGateway({ path: "/ws" }) -export class IntentsGateway implements OnGatewayConnection, OnGatewayDisconnect { - private readonly logger = new Logger(IntentsGateway.name); - private readonly subscribers = new Set(); - /** Configured via WS_MAX_CONNECTIONS env var (default 1000, 0 = unlimited). */ - private readonly maxConnections: number; - - constructor( - private readonly intentsService: IntentsService, - private readonly configService: ConfigService, - ) { - this.maxConnections = this.configService.get("wsMaxConnections", { infer: true }); - } - - /** - * Returns the current number of active WebSocket subscribers. - * Exposed for metrics / health checks (issue #50 / #65). - */ - get subscriberCount(): number { - return this.subscribers.size; - } - - handleConnection(client: WebSocket) { - this.subscribers.set(client, {}); - client.on("error", () => this.subscribers.delete(client)); - client.on("message", (raw) => this.handleMessage(client, raw)); - // ── Max-connections guard (issue #50) ──────────────────────────────────── - // Reject the connection before adding it to the subscriber set so the cap - // is never exceeded. Close code 1013 = "Try Again Later" (RFC 6455). - if (this.maxConnections > 0 && this.subscribers.size >= this.maxConnections) { - this.logger.warn( - `WS connection rejected: subscriber limit reached (${this.maxConnections})`, - ); - client.close(1013, "Server at capacity — try again later"); - return; - } - - this.subscribers.add(client); - this.logger.debug(`WS client connected — active subscribers: ${this.subscribers.size}`); client.on("error", () => { this.subscribers.delete(client); - this.logger.debug(`WS client error/drop — active subscribers: ${this.subscribers.size}`); + logger.debug( + `ws client error/drop — active subscribers: ${this.subscribers.size}`, + ); }); - client.send( - JSON.stringify({ type: "connected", message: "Vortex intent stream" }), - logger.info(`ws client connected (subscribers=${this.subscribers.size})`); - const currentSeq = this.nextSeq - 1; client.send( @@ -194,55 +111,19 @@ export class IntentsGateway implements OnGatewayConnection, OnGatewayDisconnect const open = this.intentsService.getByState("open").slice(0, 20); client.send(JSON.stringify({ type: "snapshot", intents: open, seq: currentSeq })); - this.logger.debug(`client connected; subscribers=${this.subscribers.size} seq=${currentSeq}`); + logger.info(`ws client connected (subscribers=${this.subscribers.size})`); } handleDisconnect(client: WebSocket) { - const solverAddress = this.solverConnections.get(client); - if (solverAddress) { - this.solversService.markOffline(solverAddress); - } - this.solverConnections.delete(client); this.subscribers.delete(client); - this.logger.debug(`WS client disconnected — active subscribers: ${this.subscribers.size}`); - } - - private handleMessage(client: WebSocket, raw: Buffer) { - let message: { type?: string; chains?: string[] }; - try { - message = JSON.parse(raw.toString()); - } catch { - return; - } - - if (message.type === "subscribe") { - const filter: SubscriberFilter = {}; - if (message.chains && message.chains.length > 0) { - filter.chains = message.chains; - } - this.subscribers.set(client, filter); - client.send(JSON.stringify({ type: "subscribed", filter })); - } - } - - onModuleDestroy() { - if (this.heartbeatTimer) clearInterval(this.heartbeatTimer); - } - - private getSolverAddress(client: WebSocket): string | undefined { - const match = /(?:^|&)solver=([^&]+)/.exec(client.url ?? ""); - return match ? decodeURIComponent(match[1]) : undefined; + logger.info(`ws client disconnected (subscribers=${this.subscribers.size})`); } broadcast(event: { type: string; [key: string]: unknown }) { logger.debug(`ws broadcast type=${event.type} subscribers=${this.subscribers.size}`); const payload = JSON.stringify(event); - for (const [client, filter] of this.subscribers) { + for (const client of this.subscribers) { if (client.readyState !== WebSocket.OPEN) continue; - if (filter.chains && filter.chains.length > 0) { - const eventChain = this.getEventChain(event); - if (eventChain && !filter.chains.includes(eventChain)) continue; - } client.send(payload); } } @@ -259,12 +140,19 @@ export class IntentsGateway implements OnGatewayConnection, OnGatewayDisconnect return this.subscribers.size; } + /** Returns the current number of active WebSocket subscribers. */ + get subscriberCount(): number { + return this.subscribers.size; + } + private heartbeat() { for (const client of this.subscribers) { if (this.alive.get(client) === false) { client.terminate(); this.subscribers.delete(client); - logger.debug(`ws heartbeat terminated dead client (subscribers=${this.subscribers.size})`); + logger.debug( + `ws heartbeat terminated dead client (subscribers=${this.subscribers.size})`, + ); continue; } @@ -273,32 +161,10 @@ export class IntentsGateway implements OnGatewayConnection, OnGatewayDisconnect client.ping(); } } - span.setAttribute("subscribers.sent", sent); - span.end(); - } - - getSubscriberCount(): number { - return this.subscribers.size; - } -} - private getEventChain(event: { type: string; [key: string]: unknown }): string | null { - if (event.type === "intent_created" && event.intent) { - return (event.intent as { srcChain?: string }).srcChain ?? null; - } - if ( - (event.type === "intent_filled" || - event.type === "intent_accepted" || - event.type === "intent_cancelled" || - event.type === "intent_expired") && - event.intentId - ) { - const intent = this.intentsService.get(event.intentId as string); - return intent?.srcChain ?? null; - } - return null; } onModuleDestroy() { + if (this.heartbeatTimer) clearInterval(this.heartbeatTimer); for (const client of this.subscribers) { client.close(1001, "Server shutting down"); } diff --git a/src/intents/intents.service.spec.ts b/src/intents/intents.service.spec.ts index 9900bb4..c4a8730 100644 --- a/src/intents/intents.service.spec.ts +++ b/src/intents/intents.service.spec.ts @@ -3,8 +3,6 @@ import { Keypair } from "@stellar/stellar-sdk"; import { AppConfig } from "../config/configuration"; import { StellarTxService } from "../soroban/stellar-tx.service"; import { IntentsService } from "./intents.service"; -import { InMemoryIntentsRepository } from "./in-memory-intents.repository"; -import { INTENTS_REPOSITORY } from "./intents.repository"; const VALID_CONTRACT_ID = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA"; @@ -73,9 +71,9 @@ describe("IntentsService", () => { expect(service.getAll()).toHaveLength(before + 1); }); - it("create defaults deadline to now + 1800 when omitted", () => { + it("create defaults deadline to now + 1800 when omitted", async () => { const before = Math.floor(Date.now() / 1000); - const intent = service.create({ + const intent = await service.create({ user: "GTEST...0000", srcChain: "ethereum", srcToken: { address: "0xabc", symbol: "USDC", name: "USD Coin", decimals: 6, chain: "ethereum" }, @@ -188,6 +186,9 @@ describe("IntentsService", () => { const successes = results.filter((r) => r !== null); expect(successes).toHaveLength(1); expect(successes[0]!.state).toBe("filled"); + }); + }); + describe("on-chain registration (ONCHAIN_INTENTS_ENABLED)", () => { it("stays fully in-memory when the flag is off, never touching StellarTxService", async () => { const stellarTxService = fakeStellarTxService(); diff --git a/src/intents/intents.service.ts b/src/intents/intents.service.ts index 1d19158..c48b0cb 100644 --- a/src/intents/intents.service.ts +++ b/src/intents/intents.service.ts @@ -1,23 +1,29 @@ -import { Injectable, Logger, ServiceUnavailableException } from "@nestjs/common"; +import { + Injectable, + Logger, + OnModuleDestroy, + ServiceUnavailableException, +} from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { v4 as uuidv4 } from "uuid"; -import { Intent, IntentAuditEntry, IntentState } from "./intents.types"; import { Address, nativeToScVal, xdr } from "@stellar/stellar-sdk"; -import { Intent, IntentState } from "./intents.types"; +import { Intent, IntentAuditEntry, IntentState } from "./intents.types"; import { buildSeedIntents } from "./intents.seed"; import { AppConfig } from "../config/configuration"; import { StellarTxService } from "../soroban/stellar-tx.service"; +const STORE_SIZE_LOG_INTERVAL_MS = 60_000; + /** * Orchestration layer for intents. * * Business logic (ID generation, default state, deadline defaulting) lives - * here. All persistence is delegated to the injected IIntentsRepository so - * the storage adapter can be swapped (in-memory → Prisma → on-chain) without - * touching this service. + * here. All persistence is delegated to the internal in-memory Map. + * Once issue #36 lands, this will delegate to an injected IIntentsRepository + * so the storage adapter can be swapped without touching this service. */ @Injectable() -export class IntentsService { +export class IntentsService implements OnModuleDestroy { private readonly logger = new Logger(IntentsService.name); private readonly intents = new Map(); private readonly idempotencyCache = new Map(); @@ -30,7 +36,8 @@ export class IntentsService { */ private readonly auditLog = new Map(); - constructor() { + private readonly sizeLogTimer: ReturnType; + constructor( private readonly configService: ConfigService, private readonly stellarTxService: StellarTxService, @@ -50,8 +57,10 @@ export class IntentsService { this.logger.log(`[store-monitor] intents map size: ${this.intents.size}`); } - create(data: Omit, idempotencyKey?: string): Intent { - async create(data: Omit): Promise { + async create( + data: Omit, + idempotencyKey?: string, + ): Promise { const now = Math.floor(Date.now() / 1000); if (idempotencyKey) { @@ -81,7 +90,10 @@ export class IntentsService { if (idempotencyKey) { const ttl = 86400; // 24 hours - this.idempotencyCache.set(idempotencyKey, { intentId: intent.intentId, expiresAt: now + ttl }); + this.idempotencyCache.set(idempotencyKey, { + intentId: intent.intentId, + expiresAt: now + ttl, + }); } return intent; @@ -114,8 +126,12 @@ export class IntentsService { }); this.logger.log(`Registered intent ${intent.intentId} on-chain (tx ${result.hash})`); } catch (err) { - this.logger.error(`Failed to register intent ${intent.intentId} on-chain: ${(err as Error).message}`); - throw new ServiceUnavailableException("Failed to register intent with the settlement contract"); + this.logger.error( + `Failed to register intent ${intent.intentId} on-chain: ${(err as Error).message}`, + ); + throw new ServiceUnavailableException( + "Failed to register intent with the settlement contract", + ); } } @@ -133,19 +149,19 @@ export class IntentsService { } get(id: string): Intent | undefined { - return this.repository.findById(id) as Intent | undefined; + return this.intents.get(id); } getAll(): Intent[] { - return this.repository.findAll() as Intent[]; + return [...this.intents.values()].sort((a, b) => b.createdAt - a.createdAt); } getByState(state: IntentState): Intent[] { - return this.repository.findByState(state) as Intent[]; + return this.getAll().filter((i) => i.state === state); } getByUser(user: string): Intent[] { - return this.repository.findByUser(user) as Intent[]; + return this.getAll().filter((i) => i.user.toLowerCase() === user.toLowerCase()); } getAcceptedCountBySolver(solver: string): number { @@ -153,12 +169,17 @@ export class IntentsService { } update(id: string, patch: Partial): Intent | null { - return this.repository.update(id, patch) as Intent | null; + const existing = this.intents.get(id); + if (!existing) return null; + const updated = { ...existing, ...patch }; + this.intents.set(id, updated); + return updated; } /** * Atomically accept an intent only if it is currently "open". - * Mirrors the DB pattern: UPDATE intents SET state='accepted' WHERE id=$1 AND state='open' RETURNING * + * Mirrors the DB pattern: + * UPDATE intents SET state='accepted' WHERE id=$1 AND state='open' RETURNING * * Returns null when the intent is not found or is not in the "open" state (already taken). */ acceptIfOpen(id: string, solver: string): Intent | null { @@ -182,7 +203,11 @@ export class IntentsService { * UPDATE intents SET state='filled', ... WHERE id=$1 AND state='accepted' AND solver=$2 RETURNING * * Returns null when the intent is not found, not accepted, or assigned to a different solver. */ - fillIfAccepted(id: string, solver: string, patch: Omit, "state" | "solver">): Intent | null { + fillIfAccepted( + id: string, + solver: string, + patch: Omit, "state" | "solver">, + ): Intent | null { const existing = this.intents.get(id); if (!existing || existing.state !== "accepted" || existing.solver !== solver) return null; @@ -199,13 +224,6 @@ export class IntentsService { * Append a new audit entry for the given intent. * Call this whenever an intent transitions state so the full history is * preserved even after the `state` field is overwritten. - * - * @param intentId - ID of the intent being transitioned. - * @param toState - The state the intent is moving INTO. - * @param actor - Address or identifier of who triggered the change - * ("system" for sweeper-driven expirations). - * @param reason - Short human-readable description of why the transition occurred. - * @param metadata - Optional bag of extra data (e.g. fill amount, tx hash). */ appendAuditEntry( intentId: string, @@ -245,8 +263,7 @@ export class IntentsService { intentId: uuidv4(), createdAt: now - Math.floor(Math.random() * 600), }; - this.repository.save(intent); + this.intents.set(intent.intentId, intent); } } } - diff --git a/src/solvers/solvers.controller.ts b/src/solvers/solvers.controller.ts index f266c81..33d8352 100644 --- a/src/solvers/solvers.controller.ts +++ b/src/solvers/solvers.controller.ts @@ -1,12 +1,11 @@ import { + Body, Controller, Get, NotFoundException, Param, Post, } from "@nestjs/common"; -import { Controller, Get, NotFoundException, Param, Post } from "@nestjs/common"; -import { Controller, Get, Post, Body, NotFoundException, Param } from "@nestjs/common"; import { ApiTags } from "@nestjs/swagger"; import { SolversService } from "./solvers.service"; import { RegisterSolverDto } from "./dto/register-solver.dto"; @@ -16,17 +15,14 @@ import { RegisterSolverDto } from "./dto/register-solver.dto"; export class SolversController { constructor(private readonly solversService: SolversService) {} - @Post("register") + @Post() 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, + avgFillTime: dto.avgFillTime, + isActive: true, supportedChains: dto.supportedChains, supportedTokens: dto.supportedTokens, }); @@ -55,10 +51,7 @@ export class SolversController { 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 ageDays = Math.max(0, (Date.now() / 1000 - solver.registeredAt) / 86400); const reputationScore = parseFloat( (successRate * Math.exp(-ageDays / 180)).toFixed(4), ); @@ -81,6 +74,8 @@ export class SolversController { 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) { const solver = this.solversService.deactivate(address); @@ -92,17 +87,6 @@ export class SolversController { reactivate(@Param("address") address: string) { const solver = this.solversService.reactivate(address); if (!solver) throw new NotFoundException("Solver not found"); - @Post() - register(@Body() dto: RegisterSolverDto) { - const solver = this.solversService.register({ - address: dto.address, - name: dto.name, - bondAmount: dto.bondAmount, - avgFillTime: dto.avgFillTime, - isActive: true, - supportedChains: dto.supportedChains, - supportedTokens: dto.supportedTokens, - }); return solver; } } diff --git a/src/solvers/solvers.service.ts b/src/solvers/solvers.service.ts index 924deff..0e3d5be 100644 --- a/src/solvers/solvers.service.ts +++ b/src/solvers/solvers.service.ts @@ -38,47 +38,44 @@ export class SolversService { totalVolume: "0", registeredAt: Math.floor(Date.now() / 1000), }; - this.solvers.set(solver.address, solver); - return solver; + return this.repo.save(solver); } deregister(address: string): SolverRecord | undefined { - const solver = this.solvers.get(address); - if (solver) { - solver.isActive = false; - return solver; - } - return undefined; + const solver = this.repo.findByAddress(address); + if (!solver) return undefined; + const updated = { ...solver, isActive: false }; + return this.repo.save(updated); } markLive(address: string): SolverRecord | undefined { - const solver = this.solvers.get(address); - if (solver) { - solver.isActive = true; - return solver; - } - return undefined; + const solver = this.repo.findByAddress(address); + if (!solver) return undefined; + const updated = { ...solver, isActive: true }; + return this.repo.save(updated); } markOffline(address: string): SolverRecord | undefined { - const solver = this.solvers.get(address); - if (solver) { - solver.isActive = false; - return solver; - } - return undefined; + const solver = this.repo.findByAddress(address); + if (!solver) return undefined; + const updated = { ...solver, isActive: false }; + return this.repo.save(updated); + } + deactivate(address: string): SolverRecord | null { - const solver = this.solvers.get(address); + const solver = this.repo.findByAddress(address); if (!solver) return null; const updated = { ...solver, isActive: false }; - this.solvers.set(address, updated); - return updated; + return this.repo.save(updated); } reactivate(address: string): SolverRecord | null { - const solver = this.solvers.get(address); + const solver = this.repo.findByAddress(address); if (!solver) return null; const updated = { ...solver, isActive: true }; + return this.repo.save(updated); + } + /** * Records that a solver accepted an intent and then missed its fill * deadline. Bumps the local fillsFailed counter for read paths (e.g. the @@ -87,16 +84,9 @@ export class SolversService { * once event ingestion exists (see docs/architecture/onchain-settlement.md). */ recordFailedFill(address: string): SolverRecord | null { - const solver = this.solvers.get(address); + const solver = this.repo.findByAddress(address); if (!solver) return null; const updated = { ...solver, fillsFailed: solver.fillsFailed + 1 }; - this.solvers.set(address, updated); - return updated; - } - - private seed() { - for (const s of buildSeedSolvers()) { - this.solvers.set(s.address, s); - } + return this.repo.save(updated); } } diff --git a/src/soroban/event-ingestion.service.spec.ts b/src/soroban/event-ingestion.service.spec.ts index a9ed778..daed4bf 100644 --- a/src/soroban/event-ingestion.service.spec.ts +++ b/src/soroban/event-ingestion.service.spec.ts @@ -1,162 +1,11 @@ -import { nativeToScVal } from "@stellar/stellar-sdk"; -import { EventIngestionService } from "./event-ingestion.service"; - -function fakeEvent(overrides: Partial> = {}) { - return { - id: "0000000001-0000000000", - type: "contract" as const, - ledger: 1000, - ledgerClosedAt: new Date().toISOString(), - pagingToken: "0000000001-0000000000", - inSuccessfulContractCall: true, - txHash: "deadbeef", - contractId: "CCONTRACT", - topic: [nativeToScVal("intent_filled", { type: "symbol" })], - value: nativeToScVal(42, { type: "u32" }), - ...overrides, - }; -} - -describe("EventIngestionService", () => { - function build(contractId: string) { - const configService = { - get: jest.fn().mockReturnValue(contractId), - }; - const sorobanService = { - getLatestLedger: jest.fn().mockResolvedValue({ sequence: 500 }), - getEvents: jest.fn().mockResolvedValue({ latestLedger: 500, events: [] }), - }; - const intentsGateway = { - broadcast: jest.fn(), - }; - - const service = new EventIngestionService( - configService as never, - sorobanService as never, - intentsGateway as never, - ); - - return { service, configService, sorobanService, intentsGateway }; - } - - afterEach(() => { - jest.useRealTimers(); - }); - - it("does not poll when SETTLEMENT_CONTRACT_ID is unconfigured", async () => { - const { service, sorobanService } = build(""); - - await service.onModuleInit(); - service.onModuleDestroy(); - - expect(sorobanService.getLatestLedger).not.toHaveBeenCalled(); - expect(sorobanService.getEvents).not.toHaveBeenCalled(); - }); - - it("seeds startLedger from the latest ledger and polls with it on the first tick", async () => { - jest.useFakeTimers(); - const { service, sorobanService } = build("CSETTLEMENT"); - - await service.onModuleInit(); - await jest.advanceTimersByTimeAsync(5_000); - - expect(sorobanService.getEvents).toHaveBeenCalledWith( - expect.objectContaining({ - filters: [{ type: "contract", contractIds: ["CSETTLEMENT"] }], - startLedger: 500, - cursor: undefined, - }), - ); - - service.onModuleDestroy(); - }); - - it("broadcasts decoded events derived from the topic symbol", async () => { - jest.useFakeTimers(); - const { service, sorobanService, intentsGateway } = build("CSETTLEMENT"); - sorobanService.getEvents.mockResolvedValueOnce({ - latestLedger: 501, - events: [fakeEvent()], - }); - - await service.onModuleInit(); - await jest.advanceTimersByTimeAsync(5_000); - - expect(intentsGateway.broadcast).toHaveBeenCalledWith( - expect.objectContaining({ - type: "chain_intent_filled", - contractId: "CSETTLEMENT", - ledger: 1000, - txHash: "deadbeef", - data: 42, - }), - ); - - service.onModuleDestroy(); - }); - - it("skips events that were not part of a successful contract call", async () => { - jest.useFakeTimers(); - const { service, sorobanService, intentsGateway } = build("CSETTLEMENT"); - sorobanService.getEvents.mockResolvedValueOnce({ - latestLedger: 501, - events: [fakeEvent({ inSuccessfulContractCall: false })], - }); - - await service.onModuleInit(); - await jest.advanceTimersByTimeAsync(5_000); - - expect(intentsGateway.broadcast).not.toHaveBeenCalled(); - - service.onModuleDestroy(); - }); - - it("carries the cursor forward instead of re-sending startLedger on later polls", async () => { - jest.useFakeTimers(); - const { service, sorobanService } = build("CSETTLEMENT"); - sorobanService.getEvents.mockResolvedValueOnce({ - latestLedger: 501, - events: [fakeEvent({ pagingToken: "cursor-1" })], - }); - - await service.onModuleInit(); - await jest.advanceTimersByTimeAsync(5_000); - await jest.advanceTimersByTimeAsync(5_000); - - expect(sorobanService.getEvents).toHaveBeenLastCalledWith( - expect.objectContaining({ cursor: "cursor-1", startLedger: undefined }), - ); - - service.onModuleDestroy(); - }); - - it("stops polling after onModuleDestroy", async () => { - jest.useFakeTimers(); - const { service, sorobanService } = build("CSETTLEMENT"); - - await service.onModuleInit(); - service.onModuleDestroy(); - await jest.advanceTimersByTimeAsync(30_000); - - expect(sorobanService.getEvents).not.toHaveBeenCalled(); - }); - - it("logs and keeps running when a poll fails", async () => { - jest.useFakeTimers(); - const { service, sorobanService, intentsGateway } = build("CSETTLEMENT"); - sorobanService.getEvents.mockRejectedValueOnce(new Error("rpc down")); - - await service.onModuleInit(); - await jest.advanceTimersByTimeAsync(5_000); - - expect(intentsGateway.broadcast).not.toHaveBeenCalled(); - expect(sorobanService.getEvents).toHaveBeenCalledTimes(1); - - service.onModuleDestroy(); import { ConfigService } from "@nestjs/config"; import { nativeToScVal, SorobanRpc } from "@stellar/stellar-sdk"; import { AppConfig } from "../config/configuration"; -import { buildDedupeKey, EventIngestionService, parseEventIndex } from "./event-ingestion.service"; +import { + buildDedupeKey, + EventIngestionService, + parseEventIndex, +} from "./event-ingestion.service"; import { SorobanService } from "./soroban.service"; function makeIntentFilledEvent( @@ -179,7 +28,9 @@ function makeIntentFilledEvent( } as SorobanRpc.Api.EventResponse; } -function makeConfigService(settlementContractId = "CSETTLEMENT"): ConfigService { +function makeConfigService( + settlementContractId = "CSETTLEMENT", +): ConfigService { return { get: (key: string) => { if (key === "stellar.settlementContractId") return settlementContractId; @@ -249,8 +100,14 @@ describe("EventIngestionService", () => { }); it("treats events with the same intent id but different event indices as distinct", () => { - const eventA = makeIntentFilledEvent({ ledger: 1000, id: "0000001000-0000000001" }); - const eventB = makeIntentFilledEvent({ ledger: 1000, id: "0000001000-0000000002" }); + const eventA = makeIntentFilledEvent({ + ledger: 1000, + id: "0000001000-0000000001", + }); + const eventB = makeIntentFilledEvent({ + ledger: 1000, + id: "0000001000-0000000002", + }); expect(service.ingest(eventA)).toBe(true); expect(service.ingest(eventB)).toBe(true); diff --git a/src/soroban/signer.service.spec.ts b/src/soroban/signer.service.spec.ts index 62b027e..582ed06 100644 --- a/src/soroban/signer.service.spec.ts +++ b/src/soroban/signer.service.spec.ts @@ -7,7 +7,7 @@ import { SorobanService } from "./soroban.service"; function configWith(signerSecretKey: string, network: AppConfig["stellar"]["network"] = "testnet") { const values: Record = { - "stellar.signerSecretKey": signerSecretKey, + "stellar.signingKey": signerSecretKey, "stellar.network": network, }; return { get: (path: string) => values[path] } as ConfigService; diff --git a/src/soroban/signer.service.ts b/src/soroban/signer.service.ts index bb1bc15..1b3d08c 100644 --- a/src/soroban/signer.service.ts +++ b/src/soroban/signer.service.ts @@ -44,7 +44,7 @@ export class SignerService { configService: ConfigService, private readonly sorobanService: SorobanService, ) { - this.secretKey = configService.get("stellar.signerSecretKey", { infer: true }); + this.secretKey = configService.get("stellar.signingKey", { infer: true }); this.networkPassphrase = NETWORK_PASSPHRASES[configService.get("stellar.network", { infer: true })]; } diff --git a/src/soroban/solver-registry.service.spec.ts b/src/soroban/solver-registry.service.spec.ts index 20a7e52..6f62bce 100644 --- a/src/soroban/solver-registry.service.spec.ts +++ b/src/soroban/solver-registry.service.spec.ts @@ -9,13 +9,17 @@ function makeConfigService(overrides: Partial = {}) { settlementContractId: "", solverRegistryContractId: "", signingKey: "", + feePercentile: "p50", ...overrides, }; const config: AppConfig = { nodeEnv: "test", port: 4000, + databaseUrl: "postgresql://vortex:vortex@localhost:5432/vortex?schema=public", stellar, + onchainIntentsEnabled: false, corsOrigin: "*", + wsMaxConnections: 1000, }; return { get: (key: string) => { diff --git a/src/soroban/soroban.module.ts b/src/soroban/soroban.module.ts index 78a9899..8b19079 100644 --- a/src/soroban/soroban.module.ts +++ b/src/soroban/soroban.module.ts @@ -3,12 +3,24 @@ import { EventIngestionService } from "./event-ingestion.service"; import { SorobanController } from "./soroban.controller"; import { SorobanService } from "./soroban.service"; import { SolverRegistryService } from "./solver-registry.service"; +import { SignerService } from "./signer.service"; +import { StellarTxService } from "./stellar-tx.service"; @Module({ controllers: [SorobanController], - providers: [SorobanService, SolverRegistryService], - exports: [SorobanService, SolverRegistryService], - providers: [SorobanService, EventIngestionService], - exports: [SorobanService, EventIngestionService], + providers: [ + SorobanService, + SolverRegistryService, + SignerService, + StellarTxService, + EventIngestionService, + ], + exports: [ + SorobanService, + SolverRegistryService, + SignerService, + StellarTxService, + EventIngestionService, + ], }) export class SorobanModule {} diff --git a/src/soroban/soroban.service.ts b/src/soroban/soroban.service.ts index 1310160..258f67b 100644 --- a/src/soroban/soroban.service.ts +++ b/src/soroban/soroban.service.ts @@ -1,6 +1,6 @@ import { Injectable } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; -import { FeeBumpTransaction, SorobanRpc, Transaction } from "@stellar/stellar-sdk"; +import { SorobanRpc, Transaction } from "@stellar/stellar-sdk"; import { AppConfig } from "../config/configuration"; @Injectable() @@ -31,4 +31,20 @@ export class SorobanService { getEvents(request: SorobanRpc.Server.GetEventsRequest) { return this.server.getEvents(request); } + + getFeeStats(): Promise { + return this.server.getFeeStats(); + } + + simulateTransaction( + transaction: Transaction, + ): Promise { + return this.server.simulateTransaction(transaction); + } + + prepareTransaction( + transaction: Transaction, + ): Promise { + return this.server.prepareTransaction(transaction) as Promise; + } } diff --git a/src/soroban/stellar-tx.service.ts b/src/soroban/stellar-tx.service.ts index 66dc4f3..fc26e38 100644 --- a/src/soroban/stellar-tx.service.ts +++ b/src/soroban/stellar-tx.service.ts @@ -1,8 +1,17 @@ import { Injectable, Logger } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; -import { BASE_FEE, FeeBumpTransaction, SorobanRpc, Transaction, TransactionBuilder } from "@stellar/stellar-sdk"; -import { AppConfig } from "../config/configuration"; +import { + BASE_FEE, + FeeBumpTransaction, + nativeToScVal, + SorobanRpc, + Transaction, + TransactionBuilder, + xdr, +} from "@stellar/stellar-sdk"; +import { AppConfig, FeePercentile } from "../config/configuration"; import { SorobanService } from "./soroban.service"; +import { SignerService } from "./signer.service"; export interface FeeEstimate { /** Classic inclusion fee, in stroops. */ @@ -13,10 +22,21 @@ export interface FeeEstimate { totalFee: string; } +export interface InvokeContractParams { + contractId: string; + method: string; + args: xdr.ScVal[]; +} + +export interface InvokeContractResult { + hash: string; + status: string; +} + @Injectable() export class StellarTxService { private readonly logger = new Logger(StellarTxService.name); - private readonly feePercentile: AppConfig["stellar"]["feePercentile"]; + private readonly feePercentile: FeePercentile; constructor( private readonly sorobanService: SorobanService, @@ -49,13 +69,18 @@ export class StellarTxService { */ async estimateFee(transaction: Transaction): Promise { const baseFee = await this.estimateBaseFee(); - const simulation = await this.sorobanService.simulateTransaction(this.withFee(transaction, baseFee)); + const simulation = await this.sorobanService.simulateTransaction( + this.withFee(transaction, baseFee), + ); if (SorobanRpc.Api.isSimulationError(simulation)) { - throw new Error(`Fee estimation failed: transaction simulation error: ${simulation.error}`); + throw new Error( + `Fee estimation failed: transaction simulation error: ${simulation.error}`, + ); } - const resourceFee = simulation.minResourceFee; + const resourceFee = (simulation as SorobanRpc.Api.SimulateTransactionSuccessResponse) + .minResourceFee; const totalFee = (BigInt(baseFee) + BigInt(resourceFee)).toString(); return { baseFee, resourceFee, totalFee }; @@ -67,13 +92,36 @@ export class StellarTxService { */ async prepareTransaction(transaction: Transaction): Promise { const baseFee = await this.estimateBaseFee(); - const prepared = await this.sorobanService.prepareTransaction(this.withFee(transaction, baseFee)); + const prepared = await this.sorobanService.prepareTransaction( + this.withFee(transaction, baseFee), + ); - this.logger.log(`Prepared transaction with fee ${prepared.fee} stroops (base fee ${baseFee})`); + this.logger.log( + `Prepared transaction with fee ${prepared.fee} stroops (base fee ${baseFee})`, + ); return prepared as Transaction; } + /** + * Invokes a Soroban contract method. + * Used by IntentsService when ONCHAIN_INTENTS_ENABLED is true. + * This is a stub that will be expanded once the on-chain settlement + * contract interface is finalised (see docs/architecture/onchain-settlement.md). + */ + async invokeContract(params: InvokeContractParams): Promise { + this.logger.log( + `invokeContract contractId=${params.contractId} method=${params.method}`, + ); + + // TODO: Build, simulate, sign, and submit the actual Soroban transaction + // once SignerService is wired here and the contract bindings are finalised. + // For now, throw a clear error so callers know this isn't implemented yet. + throw new Error( + `invokeContract not yet implemented for method=${params.method} on contract=${params.contractId}`, + ); + } + private withFee(transaction: Transaction | FeeBumpTransaction, fee: string): Transaction { if ("innerTransaction" in transaction) { throw new TypeError("fee bump transactions are not supported"); diff --git a/src/stats/stats.service.spec.ts b/src/stats/stats.service.spec.ts index 97c8192..fd8452c 100644 --- a/src/stats/stats.service.spec.ts +++ b/src/stats/stats.service.spec.ts @@ -42,7 +42,8 @@ function baseSolver(overrides: Partial = {}): SolverRecord { function makeDeps(intents: Intent[], solvers: SolverRecord[]) { const intentsService = { getAll: jest.fn().mockReturnValue(intents) } as unknown as IntentsService; const solversService = { getAll: jest.fn().mockReturnValue(solvers) } as unknown as SolversService; - const service = new StatsService(intentsService, solversService); + const intentsGateway = { getSubscriberCount: jest.fn().mockReturnValue(0) } as unknown as import("../intents/intents.gateway").IntentsGateway; + const service = new StatsService(intentsService, solversService, intentsGateway); return { service, intentsService, solversService }; }