From eb12bbc95d24f1243359a91d890d406b9b287a89 Mon Sep 17 00:00:00 2001 From: SIlas Mantisa Date: Mon, 31 Aug 2026 11:27:18 +0000 Subject: [PATCH 1/4] fix: paginate open and user intent listings --- CHANGELOG.md | 9 +++++++ src/intents/dto/list-intents.dto.ts | 12 +++++---- src/intents/intents.controller.ts | 40 +++++++++++++++++++++++------ 3 files changed, 48 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 599b189..d5ae947 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,15 @@ Commit message format is enforced via [commitlint](https://commitlint.js.org/) s (Closes #137) ### Fixed +- Added pagination envelopes to `GET /api/v1/intents/open` and + `GET /api/v1/intents/user/:address` with a safe default page size and + validation guardrails (Closes #277) +- Added bounded eviction for stale terminal-state intents in the in-memory + store, keeping the retention window configurable and auditable (Closes #278) +- Added `GET /api/v1/solvers/:address/eligible-intents` to filter open intents + by solver capability matches (Closes #279) +- Added pagination to `GET /api/v1/intents/:id/audit` while preserving the + oldest-first log ordering (Closes #281) - `TokensModule` was missing `exports: [TokensService]` — `IntentsController` could not inject `TokensService` outside the Jest test environment - `IntentsModule` was missing `exports: [IntentsGateway]` — `StatsService` diff --git a/src/intents/dto/list-intents.dto.ts b/src/intents/dto/list-intents.dto.ts index dca9191..f911525 100644 --- a/src/intents/dto/list-intents.dto.ts +++ b/src/intents/dto/list-intents.dto.ts @@ -1,5 +1,5 @@ import { IsInt, IsOptional, IsString, Max, Min } from "class-validator"; -import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { ApiPropertyOptional } from "@nestjs/swagger"; export class ListIntentsDto { @ApiPropertyOptional({ description: "Filter by intent state" }) @@ -17,19 +17,21 @@ export class ListIntentsDto { @IsString() chain?: string; - @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 c072a02..d550792 100644 --- a/src/intents/intents.controller.ts +++ b/src/intents/intents.controller.ts @@ -76,15 +76,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") @@ -134,11 +150,19 @@ 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 entries = this.intentsService.getAuditLog(id, limit, offset); + const total = this.intentsService.getAuditLog(id).length; + return { intentId: id, entries, total, limit, offset }; } /** From 78422291b5d1bd32e00816b3c5f2af53cfa49ee3 Mon Sep 17 00:00:00 2001 From: SIlas Mantisa Date: Mon, 31 Aug 2026 11:28:08 +0000 Subject: [PATCH 2/4] fix: bound stale terminal intent retention --- src/config/configuration.ts | 4 ++ src/config/env.validation.ts | 2 + src/intents/intents.repository.ts | 10 +++++ src/intents/intents.service.ts | 53 +++++++++++++++++++++--- src/intents/prisma-intents.repository.ts | 10 +++++ 5 files changed, 74 insertions(+), 5 deletions(-) diff --git a/src/config/configuration.ts b/src/config/configuration.ts index aa84559..3e065b8 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -49,6 +49,8 @@ export interface AppConfig { feePercentile: FeePercentile; }; onchainIntentsEnabled: boolean; + intentRetentionDays: number; + intentRetentionSweepMs: number; corsOrigin: string; /** Maximum concurrent WebSocket connections (0 = unlimited). */ wsMaxConnections: number; @@ -69,6 +71,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), }); diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts index 8a2dc55..0b19ed1 100644 --- a/src/config/env.validation.ts +++ b/src/config/env.validation.ts @@ -46,6 +46,8 @@ export const envValidationSchema = Joi.object({ // to a live database. Intended for production / staging. INTENTS_PERSISTENCE: Joi.string().valid("memory", "prisma").default("memory"), SOLVERS_PERSISTENCE: Joi.string().valid("memory", "prisma").default("memory"), + INTENT_RETENTION_DAYS: Joi.number().min(1).default(30), + INTENT_RETENTION_SWEEP_MS: Joi.number().min(1000).default(60000), // ── Observability ───────────────────────────────────────────────────────── // Sentry DSN for error alerting. Omit (or leave blank) to disable Sentry. diff --git a/src/intents/intents.repository.ts b/src/intents/intents.repository.ts index f445963..87344cf 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: @@ -130,6 +136,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 e569332..1b47c89 100644 --- a/src/intents/intents.service.ts +++ b/src/intents/intents.service.ts @@ -13,8 +13,10 @@ import { AppConfig } from "../config/configuration"; import { CHAIN_DEADLINE_DEFAULTS, DEFAULT_DEADLINE_SECONDS } from "../config/configuration"; import { StellarTxService } from "../soroban/stellar-tx.service"; 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"]; /** * Orchestration layer for intents. @@ -52,7 +54,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?.(); } @@ -61,10 +64,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( @@ -282,7 +320,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 bcaec0c..79fb55d 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`. * From 94df48e491f50f959146e8dc756e6b97d9aebd87 Mon Sep 17 00:00:00 2001 From: SIlas Mantisa Date: Mon, 31 Aug 2026 11:28:32 +0000 Subject: [PATCH 3/4] feat: add solver eligible-intents filtering --- src/intents/intents.module.ts | 4 ++-- src/solvers/solvers.controller.ts | 36 +++++++++++++++++++++++++++++-- src/solvers/solvers.module.ts | 4 +++- src/solvers/solvers.service.ts | 13 +++++++++++ 4 files changed, 52 insertions(+), 5 deletions(-) diff --git a/src/intents/intents.module.ts b/src/intents/intents.module.ts index 2c14769..db5120d 100644 --- a/src/intents/intents.module.ts +++ b/src/intents/intents.module.ts @@ -1,4 +1,4 @@ -import { Module } from "@nestjs/common"; +import { Module, forwardRef } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { IntentsService } from "./intents.service"; import { IntentsController } from "./intents.controller"; @@ -15,7 +15,7 @@ import { AppConfig } from "../config/configuration"; import { PrismaService } from "../prisma/prisma.service"; @Module({ - imports: [SolversModule, RoutingModule, TokensModule, SorobanModule], + imports: [forwardRef(() => SolversModule), RoutingModule, TokensModule, SorobanModule], controllers: [IntentsController], providers: [ // Select the persistence adapter based on INTENTS_PERSISTENCE env var. diff --git a/src/solvers/solvers.controller.ts b/src/solvers/solvers.controller.ts index 7292ba1..748c21b 100644 --- a/src/solvers/solvers.controller.ts +++ b/src/solvers/solvers.controller.ts @@ -1,13 +1,20 @@ import { + BadRequestException, Body, Controller, + ForbiddenException, Get, NotFoundException, Param, Post, + Query, + Inject, + forwardRef, } from "@nestjs/common"; import { ApiTags } from "@nestjs/swagger"; -import { SolversService } from "./solvers.service"; +import { IntentsService } from "../intents/intents.service"; +import { ListIntentsDto } from "../intents/dto/list-intents.dto"; +import { SolversService, solverSupports } from "./solvers.service"; import { RegisterSolverDto } from "./dto/register-solver.dto"; import { UpdateSolverStatusDto } from "./dto/update-solver-status.dto"; import { verifyStellarSignature, buildSolverStatusMessage } from "../common/stellar-signature"; @@ -15,7 +22,11 @@ import { verifyStellarSignature, buildSolverStatusMessage } from "../common/stel @ApiTags("solvers") @Controller("api/v1/solvers") export class SolversController { - constructor(private readonly solversService: SolversService) {} + constructor( + private readonly solversService: SolversService, + @Inject(forwardRef(() => IntentsService)) + private readonly intentsService: IntentsService, + ) {} @Post() async register(@Body() dto: RegisterSolverDto) { @@ -38,6 +49,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.module.ts b/src/solvers/solvers.module.ts index c526b0a..1090fb3 100644 --- a/src/solvers/solvers.module.ts +++ b/src/solvers/solvers.module.ts @@ -1,12 +1,14 @@ -import { Module } from "@nestjs/common"; +import { Module, forwardRef } from "@nestjs/common"; import { SolversController } from "./solvers.controller"; import { SolversService } from "./solvers.service"; import { SOLVERS_REPOSITORY } from "./solvers.repository"; import { InMemorySolversRepository } from "./in-memory-solvers.repository"; import { PrismaSolversRepository } from "./prisma-solvers.repository"; import { PrismaService } from "../prisma/prisma.service"; +import { IntentsModule } from "../intents/intents.module"; @Module({ + imports: [forwardRef(() => IntentsModule)], controllers: [SolversController], providers: [ // Select the persistence adapter based on SOLVERS_PERSISTENCE env var. diff --git a/src/solvers/solvers.service.ts b/src/solvers/solvers.service.ts index 4c40181..6a09083 100644 --- a/src/solvers/solvers.service.ts +++ b/src/solvers/solvers.service.ts @@ -1,7 +1,20 @@ import { Inject, Injectable } from "@nestjs/common"; +import { SupportedChain } from "../intents/intents.types"; import { SOLVERS_REPOSITORY, ISolversRepository } from "./solvers.repository"; import { SolverRecord } from "./solvers.types"; +export function solverSupports( + solver: Pick, + chain: SupportedChain | string, + token: string, +): boolean { + if (!solver.supportedChains.includes(chain as SupportedChain) && chain !== "*") { + return false; + } + const normalizedToken = token.toUpperCase(); + return solver.supportedTokens.some((supportedToken) => supportedToken.toUpperCase() === normalizedToken); +} + /** * Orchestration layer for solver records. * From b4ace4cb325ee1f90b54702738c74ce9d22ec9a6 Mon Sep 17 00:00:00 2001 From: SIlas Mantisa Date: Mon, 31 Aug 2026 11:30:17 +0000 Subject: [PATCH 4/4] fix: paginate the intent audit log --- src/intents/intents.controller.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/intents/intents.controller.ts b/src/intents/intents.controller.ts index d550792..001e8de 100644 --- a/src/intents/intents.controller.ts +++ b/src/intents/intents.controller.ts @@ -160,8 +160,9 @@ export class IntentsController { 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 = this.intentsService.getAuditLog(id).length; + const total = allEntries.length; return { intentId: id, entries, total, limit, offset }; }