diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 3370760..736ccd6 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -74,6 +74,8 @@ export default (): AppConfig => ({ feePercentile: (process.env.SOROBAN_FEE_PERCENTILE ?? "p50") as FeePercentile, }, onchainIntentsEnabled: (process.env.ONCHAIN_INTENTS_ENABLED ?? "false") === "true", + intentRetentionDays: parseInt(process.env.INTENT_RETENTION_DAYS ?? "30", 10), + intentRetentionSweepMs: parseInt(process.env.INTENT_RETENTION_SWEEP_MS ?? "60000", 10), corsOrigin: process.env.CORS_ORIGIN ?? "*", wsMaxConnections: parseInt(process.env.WS_MAX_CONNECTIONS ?? "1000", 10), wsBackplane: (process.env.WS_BACKPLANE ?? "memory") as "memory" | "redis", diff --git a/src/intents/dto/list-intents.dto.ts b/src/intents/dto/list-intents.dto.ts index af5f261..6ac636c 100644 --- a/src/intents/dto/list-intents.dto.ts +++ b/src/intents/dto/list-intents.dto.ts @@ -29,19 +29,21 @@ export class ListIntentsDto { @IsIn(SUPPORTED_CHAINS) chain?: SupportedChain; - @ApiProperty({ minimum: 1, maximum: 100, default: 20, description: "Number of results per page" }) + @ApiPropertyOptional({ minimum: 1, maximum: 100, default: 20, description: "Number of results per page" }) + @IsOptional() @IsInt() @Min(1) @Max(100) - limit!: number; + limit?: number; @ApiPropertyOptional({ description: "Cursor for the next page of intents" }) @IsOptional() @IsString() cursor?: string; - @ApiProperty({ minimum: 0, default: 0, description: "Number of results to skip" }) + @ApiPropertyOptional({ minimum: 0, default: 0, description: "Number of results to skip" }) + @IsOptional() @IsInt() @Min(0) - offset!: number; + offset?: number; } diff --git a/src/intents/intents.controller.ts b/src/intents/intents.controller.ts index 1dbb9ef..3d0d117 100644 --- a/src/intents/intents.controller.ts +++ b/src/intents/intents.controller.ts @@ -86,15 +86,31 @@ export class IntentsController { } @Get("open") - async listOpen() { + async listOpen(@Query() dto: ListIntentsDto) { const open = await this.intentsService.getByState("open"); - return { intents: open, count: open.length }; + const limit = Math.min(dto.limit ?? 20, 100); + const offset = dto.offset ?? 0; + + if ((dto.limit ?? 20) > 100) { + throw new BadRequestException("Limit exceeds maximum allowed value of 100"); + } + + const page = open.slice(offset, offset + limit); + return { intents: page, total: open.length, count: open.length, limit, offset }; } @Get("user/:address") - async listByUser(@Param("address") address: string) { + async listByUser(@Param("address") address: string, @Query() dto: ListIntentsDto) { const intents = await this.intentsService.getByUser(address); - return { intents, count: intents.length }; + const limit = Math.min(dto.limit ?? 20, 100); + const offset = dto.offset ?? 0; + + if ((dto.limit ?? 20) > 100) { + throw new BadRequestException("Limit exceeds maximum allowed value of 100"); + } + + const page = intents.slice(offset, offset + limit); + return { intents: page, total: intents.length, count: intents.length, limit, offset }; } @Get(":id") @@ -144,11 +160,20 @@ export class IntentsController { }, }) @ApiNotFoundResponse({ description: "Intent not found" }) - getAudit(@Param("id") id: string) { - const intent = this.intentsService.get(id); + async getAudit(@Param("id") id: string, @Query() dto: ListIntentsDto) { + const intent = await this.intentsService.get(id); if (!intent) throw new NotFoundException("Intent not found"); - const entries = this.intentsService.getAuditLog(id); - return { intentId: id, entries }; + + 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 allEntries = this.intentsService.getAuditLog(id); + const entries = this.intentsService.getAuditLog(id, limit, offset); + const total = allEntries.length; + return { intentId: id, entries, total, limit, offset }; } /** diff --git a/src/intents/intents.repository.ts b/src/intents/intents.repository.ts index a39ed60..054d5c3 100644 --- a/src/intents/intents.repository.ts +++ b/src/intents/intents.repository.ts @@ -55,6 +55,12 @@ export interface IIntentsRepository { */ update(id: string, patch: Partial): Intent | null | Promise; + /** + * Remove a stored intent. Used only for in-memory retention sweeps for stale + * terminal-state records; Prisma-backed stores ignore this call by design. + */ + delete(id: string): boolean | Promise; + /** * Atomically transition an intent from `open` → `accepted` only if it is * currently in the `open` state. Mirrors the DB pattern: @@ -158,6 +164,10 @@ export class InMemoryIntentsRepository implements IIntentsRepository { return updated; } + delete(id: string): boolean { + return this.store.delete(id); + } + acceptIfOpen(id: string, solver: string, newDeadline: number): Intent | null { const existing = this.store.get(id); if (!existing || existing.state !== "open") return null; diff --git a/src/intents/intents.service.ts b/src/intents/intents.service.ts index 136399d..d86856e 100644 --- a/src/intents/intents.service.ts +++ b/src/intents/intents.service.ts @@ -17,6 +17,7 @@ import { PrismaService } from "../prisma/prisma.service"; import { INTENTS_REPOSITORY, IIntentsRepository } from "./intents.repository"; const STORE_SIZE_LOG_INTERVAL_MS = 60_000; +const TERMINAL_STATES: IntentState[] = ["filled", "cancelled", "expired", "slashed"]; /** How long a completed idempotency-key result stays replayable. */ const IDEMPOTENCY_TTL_SECONDS = 86_400; // 24 hours @@ -65,7 +66,8 @@ export class IntentsService implements OnModuleDestroy { private readonly stellarTxService: StellarTxService, private readonly prisma: PrismaService, ) { - this.sizeLogTimer = setInterval(() => this.logStoreSize(), STORE_SIZE_LOG_INTERVAL_MS); + const sweepMs = Number(this.configService.get("intentRetentionSweepMs", { infer: true }) ?? STORE_SIZE_LOG_INTERVAL_MS); + this.sizeLogTimer = setInterval(() => this.logStoreSize(), sweepMs || STORE_SIZE_LOG_INTERVAL_MS); // Allow the process to exit even if the timer is still active. this.sizeLogTimer.unref?.(); } @@ -74,10 +76,45 @@ export class IntentsService implements OnModuleDestroy { clearInterval(this.sizeLogTimer); } - /** Logs the current intent store size so unbounded growth is observable. */ + /** + * Logs the store size and evicts stale terminal intents from the in-memory + * adapter when it is the active backend. This keeps the memory footprint + * bounded without affecting on-chain or durable storage paths. + */ async logStoreSize(): Promise { + const evicted = await this.evictTerminalIntents(); + const remaining = await this.repo.findAll(); + this.logger.log(`[store-monitor] intents store size: ${remaining.length} (evicted=${evicted})`); + } + + private async evictTerminalIntents(): Promise { + const persistence = process.env.INTENTS_PERSISTENCE ?? "memory"; + const onchainEnabled = this.configService.get("onchainIntentsEnabled", { infer: true }); + if (persistence !== "memory" || onchainEnabled) { + return 0; + } + + const retentionDays = Number(this.configService.get("intentRetentionDays", { infer: true }) ?? 30); + const retentionSeconds = Math.max(0, Number.isFinite(retentionDays) ? retentionDays * 86400 : 30 * 86400); + const cutoff = Math.floor(Date.now() / 1000) - retentionSeconds; + const all = await this.repo.findAll(); - this.logger.log(`[store-monitor] intents store size: ${all.length}`); + const stale = all.filter((intent) => { + if (!TERMINAL_STATES.includes(intent.state)) return false; + const lastTerminalTs = intent.filledAt ?? intent.createdAt; + return lastTerminalTs <= cutoff; + }); + + let evicted = 0; + for (const intent of stale) { + const removed = await this.repo.delete(intent.intentId); + if (removed) evicted += 1; + this.logger.warn( + `[retention] evicted terminal intent ${intent.intentId} from in-memory store (state=${intent.state}, createdAt=${intent.createdAt})`, + ); + } + + return evicted; } async create( @@ -378,7 +415,12 @@ export class IntentsService implements OnModuleDestroy { * * Returns an empty array if the intent has no recorded transitions. */ - getAuditLog(intentId: string): IntentAuditEntry[] { - return this.auditLog.get(intentId) ?? []; + getAuditLog(intentId: string, limit?: number, offset?: number): IntentAuditEntry[] { + const entries = this.auditLog.get(intentId) ?? []; + if (limit === undefined && offset === undefined) return entries; + + const safeLimit = Math.min(limit ?? 20, 100); + const safeOffset = Math.max(0, offset ?? 0); + return entries.slice(safeOffset, safeOffset + safeLimit); } } diff --git a/src/intents/prisma-intents.repository.ts b/src/intents/prisma-intents.repository.ts index 2713131..acf8e0c 100644 --- a/src/intents/prisma-intents.repository.ts +++ b/src/intents/prisma-intents.repository.ts @@ -73,6 +73,16 @@ export class PrismaIntentsRepository implements IIntentsRepository { } } + async delete(id: string): Promise { + try { + await this.prisma.intent.delete({ where: { intentId: id } }); + return true; + } catch (err) { + if ((err as Prisma.PrismaClientKnownRequestError).code === "P2025") return false; + throw err; + } + } + /** * Atomically accept an intent only when it is currently `open`. * diff --git a/src/solvers/solvers.controller.ts b/src/solvers/solvers.controller.ts index 810c789..7c3334f 100644 --- a/src/solvers/solvers.controller.ts +++ b/src/solvers/solvers.controller.ts @@ -2,6 +2,7 @@ import { BadRequestException, Body, Controller, + ForbiddenException, Get, NotFoundException, Param, @@ -119,6 +120,27 @@ export class SolversController { return { solvers, count: solvers.length }; } + @Get(":address/eligible-intents") + async getEligibleIntents(@Param("address") address: string, @Query() dto: ListIntentsDto) { + const solver = await this.solversService.get(address); + if (!solver) throw new NotFoundException("Solver not found"); + if (!solver.isActive) throw new ForbiddenException("Solver is not active"); + + const open = await this.intentsService.getByState("open"); + const eligible = open.filter((intent) => + solverSupports(solver, intent.srcChain, intent.srcToken.symbol), + ); + + const limit = Math.min(dto.limit ?? 20, 100); + const offset = dto.offset ?? 0; + if ((dto.limit ?? 20) > 100) { + throw new BadRequestException("Limit exceeds maximum allowed value of 100"); + } + + const page = eligible.slice(offset, offset + limit); + return { intents: page, total: eligible.length, count: eligible.length, limit, offset }; + } + @Get(":address") async getSolver(@Param("address") address: string) { const solver = await this.solversService.get(address); diff --git a/src/solvers/solvers.service.ts b/src/solvers/solvers.service.ts index d03250a..a3b4d21 100644 --- a/src/solvers/solvers.service.ts +++ b/src/solvers/solvers.service.ts @@ -1,4 +1,5 @@ import { Inject, Injectable } from "@nestjs/common"; +import { SupportedChain } from "../intents/intents.types"; import { SOLVERS_REPOSITORY, ISolversRepository } from "./solvers.repository"; import { SolverRecord } from "./solvers.types";