Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/config/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 6 additions & 4 deletions src/intents/dto/list-intents.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
41 changes: 33 additions & 8 deletions src/intents/intents.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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 };
}

/**
Expand Down
10 changes: 10 additions & 0 deletions src/intents/intents.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ export interface IIntentsRepository {
*/
update(id: string, patch: Partial<Intent>): Intent | null | Promise<Intent | null>;

/**
* 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<boolean>;

/**
* Atomically transition an intent from `open` → `accepted` only if it is
* currently in the `open` state. Mirrors the DB pattern:
Expand Down Expand Up @@ -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;
Expand Down
52 changes: 47 additions & 5 deletions src/intents/intents.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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?.();
}
Expand All @@ -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<void> {
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<number> {
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(
Expand Down Expand Up @@ -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);
}
}
10 changes: 10 additions & 0 deletions src/intents/prisma-intents.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,16 @@ export class PrismaIntentsRepository implements IIntentsRepository {
}
}

async delete(id: string): Promise<boolean> {
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`.
*
Expand Down
22 changes: 22 additions & 0 deletions src/solvers/solvers.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
BadRequestException,
Body,
Controller,
ForbiddenException,
Get,
NotFoundException,
Param,
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions src/solvers/solvers.service.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down