From 0e77fd646aaab0f26ec15347355b2eb68dc90ca8 Mon Sep 17 00:00:00 2001 From: priscaenoch Date: Sun, 30 Aug 2026 22:16:32 +0000 Subject: [PATCH] feat(intents): batch lookup, enum filter validation, token rejection, manual sweep trigger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements four Stellar Wave issues: #269 — Safe manual sweep trigger - IntentsSweeperService.sweep() now returns a SweepResult summary. - New IntentsSweeperService.triggerManualSweep(source) runs exactly one sweep cycle and logs the invocation loudly (source, timestamp, result). - main.ts installs a SIGUSR2 handler that invokes it — operator-only (needs shell access), no HTTP surface. Replaces the REPL break-glass procedure in docs/runbooks/on-call.md. #270 — Enum validation for ListIntentsDto.state / chain - intents.types.ts exports INTENT_STATES (mirrors SUPPORTED_CHAINS); IntentState is now derived from it. - ListIntentsDto.state uses @IsIn(INTENT_STATES); chain uses @IsIn(SUPPORTED_CHAINS). Both stay @IsOptional(). Swagger enum: annotations added. - ?state=bogus / ?chain=bogus now return 400 with validation details instead of a silently-empty result set. #275 — Batch intent-status lookup - New POST /api/v1/intents/batch accepting { intentIds: string[] } (capped at 100 via @ArrayMaxSize). Returns the record for each found ID; missing IDs omitted. - IntentsService.getMany() reuses get() per ID (de-duplicated); Prisma adapter should implement this as a single WHERE intent_id IN (...) query. #276 — Reject unrecognised destination/source tokens - TokensService.resolveSrcTokenOrThrow / resolveDstTokenOrThrow throw a BadRequestException when the token is not in the registry. - IntentsController.create() uses them, so an unknown srcTokenAddress or dstTokenContract now returns 400 instead of creating an intent with priceUSD undefined. quote() applies the same check when a token identifier is supplied. - CHANGELOG.md notes the behaviour tightening. Tests: new tokens.service, getMany and manual-trigger unit specs; e2e coverage for the batch endpoint, enum-filter 400s and unknown-token 400s. Also restores two lines dropped by earlier merge conflicts that block compilation of the files touched here: the INTENTS_REPOSITORY import in intents.service.ts and `isActive: true` in SolversService.reactivate(). --- CHANGELOG.md | 21 ++++++ docs/runbooks/on-call.md | 36 ++++++++-- src/intents/dto/batch-lookup.dto.ts | 25 +++++++ src/intents/dto/list-intents.dto.ts | 26 +++++-- src/intents/intents-batch-lookup.spec.ts | 68 +++++++++++++++++++ .../intents-sweeper.manual-trigger.spec.ts | 67 ++++++++++++++++++ src/intents/intents-sweeper.service.ts | 43 +++++++++++- src/intents/intents.controller.ts | 53 ++++++++++++--- src/intents/intents.service.ts | 17 +++++ src/intents/intents.types.ts | 23 +++++-- src/main.ts | 12 ++++ src/solvers/solvers.service.ts | 2 +- src/tokens/tokens.service.spec.ts | 40 +++++++++++ src/tokens/tokens.service.ts | 35 +++++++++- test/intents.e2e-spec.ts | 45 ++++++++++-- test/validation-negative-paths.e2e-spec.ts | 49 +++++++++++++ 16 files changed, 526 insertions(+), 36 deletions(-) create mode 100644 src/intents/dto/batch-lookup.dto.ts create mode 100644 src/intents/intents-batch-lookup.spec.ts create mode 100644 src/intents/intents-sweeper.manual-trigger.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 599b189..9ea4d18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,27 @@ Commit message format is enforced via [commitlint](https://commitlint.js.org/) s - Husky `commit-msg` hook — runs commitlint on every local commit (Closes #137) - CI job `commitlint` — validates commit messages on every push/PR in GitHub Actions (Closes #137) +- `POST /api/v1/intents/batch` — bounded batch intent-status lookup (`{ intentIds: string[] }`, + capped at 100). Returns the current record for each found ID; unknown IDs are omitted. + Lets solver bots and history views reconcile a known set of intent IDs in one call + instead of N `GET /:id` requests (Closes #275) +- `SIGUSR2` manual sweep trigger — operator-only break-glass that runs exactly one + `IntentsSweeperService.sweep()` cycle on demand, logged loudly. Replaces the + REPL-based procedure in `docs/runbooks/on-call.md` (Closes #269) + +### Changed +- `ListIntentsDto.state` and `.chain` are now validated against the real + `IntentState` / `SUPPORTED_CHAINS` values (`@IsIn`) instead of a bare `@IsString()`. + `GET /api/v1/intents?state=bogus` (or `?chain=bogus`) now returns `400` with + validation details instead of silently returning an empty result set. Swagger + `enum` annotations added. `INTENT_STATES` is now exported from `intents.types.ts` + (Closes #270) +- Intent creation (`POST /api/v1/intents`) now rejects an unrecognised + `srcTokenAddress` or `dstTokenContract` with a `400` instead of silently creating + an intent with `priceUSD: undefined`. `POST /api/v1/intents/quote` applies the same + check when a token contract/address is supplied. **Behavior tightening:** + requests that were previously accepted with an unknown token will now be rejected + (Closes #276) ### Fixed - `TokensModule` was missing `exports: [TokensService]` — `IntentsController` diff --git a/docs/runbooks/on-call.md b/docs/runbooks/on-call.md index dc70693..d5dafa7 100644 --- a/docs/runbooks/on-call.md +++ b/docs/runbooks/on-call.md @@ -3,7 +3,7 @@ > **Scope:** This document covers the two most common on-call scenarios for > `vortex-backend`: (1) Soroban RPC dependency outages and (2) a stuck or > slow intent sweeper. -> Last updated: 2026-07-28 +> Last updated: 2026-08-30 --- @@ -160,10 +160,36 @@ complete in **single-digit milliseconds** for < 10 000 open intents. ### Manual sweep trigger (emergency) -There is no HTTP endpoint to trigger a sweep. As a break-glass measure you -can run a sweep synchronously via the Node.js REPL attached to the process, -or restart the service (the sweeper fires on the next 30-second tick after -`onModuleInit`). +The service installs a **`SIGUSR2` handler** that runs exactly one +`IntentsSweeperService.sweep()` cycle on demand. This is the supported +break-glass mechanism — do **not** attach a Node.js REPL to the process. + +**Why a signal and not an HTTP endpoint:** it requires shell access to the +host (so it is inherently operator-only and unreachable by any API client), +needs no separate secret to manage, and every invocation is logged loudly so +it shows up clearly in the incident timeline. + +```bash +# 1. Find the backend PID +pgrep -f "node dist/main.js" + +# 2. Trigger one sweep cycle +kill -USR2 +# In Kubernetes: +# kubectl exec -- kill -USR2 1 +``` + +The trigger is synchronous and idempotent — sending `SIGUSR2` again simply +runs another cycle. Confirm it ran by grepping the logs: + +```bash +grep "MANUAL SWEEP" /var/log/vortex-backend.log | tail -5 +# [sweeper] MANUAL SWEEP TRIGGERED (source=SIGUSR2, invokedAt=...) — running one sweep cycle +# [sweeper] MANUAL SWEEP COMPLETE (source=SIGUSR2, invokedAt=...): expired=N slashed=M duration=Xms +``` + +If a manual sweep is needed repeatedly, the sweeper's own 30-second interval +is broken — escalate to the service owner rather than scripting the signal. ### Diagnosis steps diff --git a/src/intents/dto/batch-lookup.dto.ts b/src/intents/dto/batch-lookup.dto.ts new file mode 100644 index 0000000..a0a2e9e --- /dev/null +++ b/src/intents/dto/batch-lookup.dto.ts @@ -0,0 +1,25 @@ +import { ArrayMaxSize, IsArray, IsString } from "class-validator"; +import { ApiProperty } from "@nestjs/swagger"; + +/** + * Body for `POST /api/v1/intents/batch` (issue #275). + * + * A solver bot tracking many concurrently-accepted intents — or a frontend + * rendering a user's full history — can reconcile a known set of intent IDs + * against current server state in one call instead of N `GET /:id` requests. + * + * `intentIds` is capped with `@ArrayMaxSize` per the hardening pattern in + * issue #24 so a single request can't fan out unbounded work. + */ +export class BatchLookupDto { + @ApiProperty({ + type: [String], + maxItems: 100, + description: + "Intent IDs to look up (max 100). IDs with no matching record are omitted from the response, not individually 404'd.", + }) + @IsArray() + @ArrayMaxSize(100) + @IsString({ each: true }) + intentIds!: string[]; +} diff --git a/src/intents/dto/list-intents.dto.ts b/src/intents/dto/list-intents.dto.ts index dca9191..af5f261 100644 --- a/src/intents/dto/list-intents.dto.ts +++ b/src/intents/dto/list-intents.dto.ts @@ -1,21 +1,33 @@ -import { IsInt, IsOptional, IsString, Max, Min } from "class-validator"; +import { IsIn, IsInt, IsOptional, IsString, Max, Min } from "class-validator"; import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { + INTENT_STATES, + IntentState, + SUPPORTED_CHAINS, + SupportedChain, +} from "../intents.types"; export class ListIntentsDto { - @ApiPropertyOptional({ description: "Filter by intent state" }) + @ApiPropertyOptional({ + description: "Filter by intent state", + enum: INTENT_STATES, + }) @IsOptional() - @IsString() - state?: string; + @IsIn(INTENT_STATES) + state?: IntentState; @ApiPropertyOptional({ description: "Filter by user address" }) @IsOptional() @IsString() user?: string; - @ApiPropertyOptional({ description: "Filter by source chain" }) + @ApiPropertyOptional({ + description: "Filter by source chain", + enum: SUPPORTED_CHAINS, + }) @IsOptional() - @IsString() - chain?: string; + @IsIn(SUPPORTED_CHAINS) + chain?: SupportedChain; @ApiProperty({ minimum: 1, maximum: 100, default: 20, description: "Number of results per page" }) @IsInt() diff --git a/src/intents/intents-batch-lookup.spec.ts b/src/intents/intents-batch-lookup.spec.ts new file mode 100644 index 0000000..c1cc7e7 --- /dev/null +++ b/src/intents/intents-batch-lookup.spec.ts @@ -0,0 +1,68 @@ +import { ConfigService } from "@nestjs/config"; +import { IntentsService } from "./intents.service"; +import { InMemoryIntentsRepository } from "./intents.repository"; +import { StellarTxService } from "../soroban/stellar-tx.service"; +import { PrismaService } from "../prisma/prisma.service"; +import { AppConfig } from "../config/configuration"; + +/** + * Issue #275 — service-layer batch lookup used by `POST /api/v1/intents/batch`. + */ +describe("IntentsService.getMany (#275)", () => { + let service: IntentsService; + + beforeEach(() => { + const config = { + get: jest.fn().mockReturnValue(false), + } as unknown as ConfigService; + const stellarTx = {} as StellarTxService; + const prisma = { + intentAuditLog: { create: jest.fn().mockResolvedValue({}) }, + } as unknown as PrismaService; + service = new IntentsService(new InMemoryIntentsRepository(), config, stellarTx, prisma); + }); + + afterEach(() => service.onModuleDestroy()); + + function makeIntent() { + return service.create({ + user: "GTESTBATCHUSER000000", + srcChain: "ethereum", + srcToken: { address: "0xabc", symbol: "USDC", name: "USD Coin", decimals: 6, chain: "ethereum" }, + srcAmount: "1000000", + dstToken: { contract: "CTEST", symbol: "USDC", decimals: 7 }, + minDstAmount: "990000", + deadline: Math.floor(Date.now() / 1000) + 1800, + }); + } + + it("returns every record when all IDs are found", async () => { + const a = await makeIntent(); + const b = await makeIntent(); + + const result = await service.getMany([a.intentId, b.intentId]); + + expect(result.map((i) => i.intentId).sort()).toEqual([a.intentId, b.intentId].sort()); + }); + + it("omits IDs with no matching record (does not 404)", async () => { + const a = await makeIntent(); + + const result = await service.getMany([a.intentId, "does-not-exist"]); + + expect(result).toHaveLength(1); + expect(result[0].intentId).toBe(a.intentId); + }); + + it("returns an empty array for empty input", async () => { + expect(await service.getMany([])).toEqual([]); + }); + + it("de-duplicates repeated IDs", async () => { + const a = await makeIntent(); + + const result = await service.getMany([a.intentId, a.intentId, a.intentId]); + + expect(result).toHaveLength(1); + }); +}); diff --git a/src/intents/intents-sweeper.manual-trigger.spec.ts b/src/intents/intents-sweeper.manual-trigger.spec.ts new file mode 100644 index 0000000..93579d8 --- /dev/null +++ b/src/intents/intents-sweeper.manual-trigger.spec.ts @@ -0,0 +1,67 @@ +import { Logger } from "@nestjs/common"; +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"; + +/** + * Issue #269 — the manual sweep trigger (operator break-glass). + * + * The trigger is signal-driven (`SIGUSR2`, wired in `main.ts`), so there is no + * HTTP surface to test for "inaccessible without a credential". What matters is + * that invoking it runs exactly one sweep cycle and logs the invocation loudly. + */ +describe("IntentsSweeperService — manual sweep trigger (#269)", () => { + function buildSweeper(): IntentsSweeperService { + const intentsService = { + getByState: jest.fn().mockResolvedValue([]), + update: jest.fn(), + appendAuditEntry: jest.fn(), + } as unknown as IntentsService; + const gateway = { broadcast: jest.fn() } as unknown as IntentsGateway; + const solversService = { recordFailedFill: jest.fn() } as unknown as SolversService; + const solverRegistry = { + slashSolver: jest.fn().mockResolvedValue({ detail: "no-op" }), + } as unknown as SolverRegistryService; + + return new IntentsSweeperService(intentsService, gateway, solversService, solverRegistry); + } + + afterEach(() => jest.restoreAllMocks()); + + it("runs exactly one sweep cycle and returns its result", async () => { + const sweeper = buildSweeper(); + const sweepSpy = jest.spyOn(sweeper, "sweep"); + + const result = await sweeper.triggerManualSweep("SIGUSR2"); + + expect(sweepSpy).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + expiredCount: 0, + slashedCount: 0, + durationMs: expect.any(Number), + }); + }); + + it("logs the invocation loudly (source + result) for the incident timeline", async () => { + const warnSpy = jest.spyOn(Logger.prototype, "warn").mockImplementation(() => undefined); + const sweeper = buildSweeper(); + + await sweeper.triggerManualSweep("SIGUSR2"); + + const messages = warnSpy.mock.calls.map((call) => String(call[0])); + expect(messages.some((m) => m.includes("MANUAL SWEEP TRIGGERED"))).toBe(true); + expect(messages.some((m) => m.includes("MANUAL SWEEP COMPLETE"))).toBe(true); + expect(messages.every((m) => m.includes("SIGUSR2"))).toBe(true); + }); + + it("propagates and logs a failure without swallowing it", async () => { + const sweeper = buildSweeper(); + jest.spyOn(sweeper, "sweep").mockRejectedValue(new Error("boom")); + const errorSpy = jest.spyOn(Logger.prototype, "error").mockImplementation(() => undefined); + + await expect(sweeper.triggerManualSweep("SIGUSR2")).rejects.toThrow("boom"); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("MANUAL SWEEP FAILED")); + }); +}); diff --git a/src/intents/intents-sweeper.service.ts b/src/intents/intents-sweeper.service.ts index f2ec0cd..d212969 100644 --- a/src/intents/intents-sweeper.service.ts +++ b/src/intents/intents-sweeper.service.ts @@ -6,6 +6,13 @@ import { SolverRegistryService } from "../soroban/solver-registry.service"; const SWEEP_INTERVAL_MS = 30_000; +/** Outcome of a single sweep cycle — returned so a manual trigger can log it. */ +export interface SweepResult { + expiredCount: number; + slashedCount: number; + durationMs: number; +} + @Injectable() export class IntentsSweeperService implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(IntentsSweeperService.name); @@ -30,10 +37,11 @@ export class IntentsSweeperService implements OnModuleInit, OnModuleDestroy { if (this.interval) clearInterval(this.interval); } - async sweep() { + async sweep(): Promise { const startMs = Date.now(); const now = Math.floor(startMs / 1000); let expiredCount = 0; + let slashedCount = 0; for (const intent of await this.intentsService.getByState("open")) { if (intent.deadline <= now) { @@ -65,6 +73,39 @@ export class IntentsSweeperService implements OnModuleInit, OnModuleDestroy { for (const intent of missedFills) { await this.slashMissedFill(intent.intentId, intent.solver, now); + slashedCount++; + } + + return { expiredCount, slashedCount, durationMs: Date.now() - startMs }; + } + + /** + * Issue #269 — safe, auditable manual sweep trigger (operator break-glass). + * + * Runs exactly one sweep cycle on demand and logs the invocation loudly — + * source, timestamp, and result — so a manual trigger is unmistakable in an + * incident timeline. Wired to `SIGUSR2` in `main.ts`; there is deliberately + * no HTTP surface, so it is not reachable by any API client. + */ + async triggerManualSweep(source: string): Promise { + const invokedAt = new Date().toISOString(); + this.logger.warn( + `[sweeper] MANUAL SWEEP TRIGGERED (source=${source}, invokedAt=${invokedAt}) — running one sweep cycle`, + ); + + try { + const result = await this.sweep(); + this.logger.warn( + `[sweeper] MANUAL SWEEP COMPLETE (source=${source}, invokedAt=${invokedAt}): ` + + `expired=${result.expiredCount} slashed=${result.slashedCount} duration=${result.durationMs}ms`, + ); + return result; + } catch (err) { + this.logger.error( + `[sweeper] MANUAL SWEEP FAILED (source=${source}, invokedAt=${invokedAt}): ` + + `${err instanceof Error ? err.message : err}`, + ); + throw err; } } diff --git a/src/intents/intents.controller.ts b/src/intents/intents.controller.ts index c072a02..a74259f 100644 --- a/src/intents/intents.controller.ts +++ b/src/intents/intents.controller.ts @@ -36,6 +36,7 @@ import { CancelIntentDto } from "./dto/cancel-intent.dto"; import { QuoteRequestDto } from "./dto/quote-request.dto"; import { QuoteResponseDto } from "./dto/quote-response.dto"; import { ListIntentsDto } from "./dto/list-intents.dto"; +import { BatchLookupDto } from "./dto/batch-lookup.dto"; import { UserThrottlerGuard } from "./user-throttler.guard"; import { verifyStellarSignature, @@ -171,12 +172,14 @@ export class IntentsController { async create(@Body() dto: CreateIntentDto) { const now = Math.floor(Date.now() / 1000); - // #219: use typed resolveToken instead of ad-hoc duck-typed any casts - const srcToken = this.tokensService.resolveSrcToken( + // #219: use typed resolveToken instead of ad-hoc duck-typed any casts. + // #276: reject unrecognised tokens outright instead of silently creating an + // intent whose priceUSD defaults to undefined. + const srcToken = this.tokensService.resolveSrcTokenOrThrow( dto.srcChain as SupportedChain, dto.srcTokenAddress, ); - const dstToken = this.tokensService.resolveDstToken(dto.dstTokenContract); + const dstToken = this.tokensService.resolveDstTokenOrThrow(dto.dstTokenContract); const intent = await this.intentsService.create( { @@ -206,6 +209,34 @@ export class IntentsController { return intent; } + /** + * POST /api/v1/intents/batch + * + * Issue #275 — bounded batch status lookup. Lets a solver bot (or a frontend + * showing a full history) reconcile a known set of intent IDs against current + * server state in one call instead of N `GET /:id` requests. + * + * `POST` (not `GET`) because the ID list can exceed a comfortable query-string + * length. Subject to the same global rate limits as every other endpoint — + * no dedicated tier. Read-only: batch accept/fill/cancel is explicitly out of + * scope. + */ + @Post("batch") + @ApiOperation({ + summary: "Batch-fetch current intent records by ID", + description: + "Returns the current record for each supplied intent ID. IDs with no " + + "matching record are omitted (not individually 404'd). Capped at 100 IDs.", + }) + @ApiOkResponse({ description: "Records for the found intent IDs, plus a count" }) + @ApiBadRequestResponse({ + description: "intentIds missing, not an array of strings, or exceeds 100 entries", + }) + async batchLookup(@Body() dto: BatchLookupDto) { + const intents = await this.intentsService.getMany(dto.intentIds); + return { intents, count: intents.length }; + } + @Post(":id/accept") @ApiNotFoundResponse({ description: "Intent not found" }) @ApiConflictResponse({ description: "Intent is not in open state" }) @@ -341,12 +372,16 @@ export class IntentsController { quote(@Body() dto: QuoteRequestDto): QuoteResponseDto { const solvers = this.solversService.getAll().filter((s) => s.isActive); - // #219: use typed resolveSrcToken / resolveDstToken — no more any casts - const srcToken = this.tokensService.resolveSrcToken( - dto.srcChain as SupportedChain, - dto.srcTokenAddress ?? "", - ); - const dstToken = this.tokensService.resolveDstToken(dto.dstTokenContract ?? ""); + // #219: use typed resolveSrcToken / resolveDstToken — no more any casts. + // #276: a quote may be requested by symbol alone (no contract/address), but + // when a token identifier IS supplied it must resolve — otherwise the quote + // engine would silently substitute a fake $1 price. + const srcToken = dto.srcTokenAddress + ? this.tokensService.resolveSrcTokenOrThrow(dto.srcChain as SupportedChain, dto.srcTokenAddress) + : undefined; + const dstToken = dto.dstTokenContract + ? this.tokensService.resolveDstTokenOrThrow(dto.dstTokenContract) + : undefined; const srcAmountBigInt = BigInt(dto.srcAmount); // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/intents/intents.service.ts b/src/intents/intents.service.ts index e569332..321280e 100644 --- a/src/intents/intents.service.ts +++ b/src/intents/intents.service.ts @@ -9,6 +9,7 @@ import { ConfigService } from "@nestjs/config"; import { v4 as uuidv4 } from "uuid"; import { Address, nativeToScVal, xdr } from "@stellar/stellar-sdk"; import { Intent, IntentAuditEntry, IntentState } from "./intents.types"; +import { INTENTS_REPOSITORY, IIntentsRepository } from "./intents.repository"; import { AppConfig } from "../config/configuration"; import { CHAIN_DEADLINE_DEFAULTS, DEFAULT_DEADLINE_SECONDS } from "../config/configuration"; import { StellarTxService } from "../soroban/stellar-tx.service"; @@ -168,6 +169,22 @@ export class IntentsService implements OnModuleDestroy { return this.repo.findByUser(user); } + /** + * Batch-fetch the current record for each of `ids` (issue #275). + * + * IDs are de-duplicated; IDs with no matching record are simply omitted from + * the result (callers get "missing" by comparing lengths, not a 404 per ID). + * + * This reuses `get()` per ID rather than adding a storage-layer method — fine + * for the in-memory adapter. Issue #1's Prisma adapter should implement this + * as a single `WHERE intent_id IN (...)` query for efficiency. + */ + async getMany(ids: string[]): Promise { + const unique = [...new Set(ids)]; + const found = await Promise.all(unique.map((id) => this.get(id))); + return found.filter((intent): intent is Intent => intent !== undefined); + } + async getAcceptedCountBySolver(solver: string): Promise { const all = await this.repo.findAll(); return all.filter((i) => i.state === "accepted" && i.solver === solver).length; diff --git a/src/intents/intents.types.ts b/src/intents/intents.types.ts index 5c5c79b..f43b1c0 100644 --- a/src/intents/intents.types.ts +++ b/src/intents/intents.types.ts @@ -35,13 +35,22 @@ export interface IntentAuditEntry { metadata?: Record; } -export type IntentState = - | "open" - | "accepted" - | "filled" - | "cancelled" - | "expired" - | "slashed"; +/** + * Single source of truth for every state an intent can be in. + * `IntentState` is derived from this tuple so DTO validators (`@IsIn`), + * Swagger `enum:` annotations, and type-checking all stay in sync + * automatically — mirrors how `SUPPORTED_CHAINS` is defined above (issue #270). + */ +export const INTENT_STATES = [ + "open", + "accepted", + "filled", + "cancelled", + "expired", + "slashed", +] as const; + +export type IntentState = (typeof INTENT_STATES)[number]; export interface TokenInfo { address: string; diff --git a/src/main.ts b/src/main.ts index a59e153..e10a08a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -12,6 +12,7 @@ import { AppConfig } from "./config/configuration"; import { LoggingInterceptor } from "./common/logging.interceptor"; import { HttpExceptionFilter } from "./common/http-exception.filter"; import { initSentry } from "./common/sentry"; +import { IntentsSweeperService } from "./intents/intents-sweeper.service"; // Initialise Sentry before the NestJS app boots so that any startup errors // are also captured. No-op when SENTRY_DSN is not set. @@ -102,6 +103,17 @@ async function bootstrap() { checkContractIdEnvVars(configService); + // Issue #269 — operator-only manual sweep trigger (break-glass). + // Send SIGUSR2 to the process (`kill -USR2 `) to run exactly one sweep + // cycle on demand. This replaces the old "attach a Node.js REPL" procedure: + // it needs shell access to the host, is not exposed over HTTP, and every + // invocation is logged loudly by IntentsSweeperService. See + // docs/runbooks/on-call.md → "Manual sweep trigger (emergency)". + const sweeper = app.get(IntentsSweeperService); + process.on("SIGUSR2", () => { + void sweeper.triggerManualSweep("SIGUSR2"); + }); + const port = configService.get("port", { infer: true }); const corsOrigin = configService.get("corsOrigin", { infer: true }); diff --git a/src/solvers/solvers.service.ts b/src/solvers/solvers.service.ts index 4c40181..0ab1983 100644 --- a/src/solvers/solvers.service.ts +++ b/src/solvers/solvers.service.ts @@ -72,7 +72,7 @@ export class SolversService { async reactivate(address: string): Promise { const solver = await this.repo.findByAddress(address); if (!solver) return null; - const updated = { ...solver, isActive }; + const updated = { ...solver, isActive: true }; return this.repo.save(updated); } diff --git a/src/tokens/tokens.service.spec.ts b/src/tokens/tokens.service.spec.ts index 4b7a4bc..4856ea4 100644 --- a/src/tokens/tokens.service.spec.ts +++ b/src/tokens/tokens.service.spec.ts @@ -1,3 +1,4 @@ +import { BadRequestException } from "@nestjs/common"; import { TokensService } from "./tokens.service"; import { SUPPORTED_TOKENS, STELLAR_TOKENS } from "./tokens.data"; @@ -123,4 +124,43 @@ describe("TokensService", () => { expect(service.resolveDstToken("")).toBeUndefined(); }); }); + + // ── #276: OrThrow variants reject unrecognised tokens ───────────────────── + + describe("resolveSrcTokenOrThrow", () => { + it("returns the resolved token for a known chain + address", () => { + const usdcAddr = SUPPORTED_TOKENS["ethereum"][0].address; + const result = service.resolveSrcTokenOrThrow("ethereum", usdcAddr); + expect(result.symbol).toBe("USDC"); + expect(result.priceUSD).toBe(1.0); + }); + + it("throws BadRequestException for an unknown address on a known chain", () => { + expect(() => + service.resolveSrcTokenOrThrow("ethereum", "0x1111111111111111111111111111111111111111"), + ).toThrow(BadRequestException); + }); + + it("throws BadRequestException for an unknown Stellar source contract", () => { + expect(() => service.resolveSrcTokenOrThrow("stellar", "CUNKNOWN")).toThrow( + BadRequestException, + ); + }); + }); + + describe("resolveDstTokenOrThrow", () => { + it("returns the resolved token for a known Stellar contract", () => { + const contract = STELLAR_TOKENS[0].contract; + const result = service.resolveDstTokenOrThrow(contract); + expect(result.contract).toBe(contract); + }); + + it("throws BadRequestException for an unknown contract", () => { + expect(() => service.resolveDstTokenOrThrow("CNOTEXIST")).toThrow(BadRequestException); + }); + + it("throws BadRequestException for an empty contract", () => { + expect(() => service.resolveDstTokenOrThrow("")).toThrow(BadRequestException); + }); + }); }); diff --git a/src/tokens/tokens.service.ts b/src/tokens/tokens.service.ts index e7872e8..0703aac 100644 --- a/src/tokens/tokens.service.ts +++ b/src/tokens/tokens.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from "@nestjs/common"; +import { BadRequestException, Injectable } from "@nestjs/common"; import { SUPPORTED_TOKENS, STELLAR_TOKENS, SourceToken, StellarToken } from "./tokens.data"; import { SupportedChain } from "../intents/intents.types"; @@ -92,6 +92,39 @@ export class TokensService { }; } + /** + * Like {@link resolveSrcToken} but throws a `BadRequestException` instead of + * returning `undefined` when the chain + address does not resolve to a token + * in the configured registry (issue #276). + * + * Use this on the write path (intent creation) where an unrecognised token + * must be rejected outright rather than silently stored with no priceUSD. + */ + resolveSrcTokenOrThrow(chain: SupportedChain, address: string): ResolvedSrcToken { + const token = this.resolveSrcToken(chain, address); + if (!token) { + throw new BadRequestException( + `Unknown source token '${address}' for chain '${chain}' in the configured token registry`, + ); + } + return token; + } + + /** + * Like {@link resolveDstToken} but throws a `BadRequestException` instead of + * returning `undefined` when the contract does not resolve to a known Stellar + * token (issue #276). + */ + resolveDstTokenOrThrow(contract: string): ResolvedDstToken { + const token = this.resolveDstToken(contract); + if (!token) { + throw new BadRequestException( + "Unknown destination token contract for the configured token registry", + ); + } + return token; + } + getByChain(chain?: string) { if (chain === "stellar") { return { tokens: STELLAR_TOKENS.map((t) => ({ ...t })), chain: "stellar" }; diff --git a/test/intents.e2e-spec.ts b/test/intents.e2e-spec.ts index 1fdac95..44af0a8 100644 --- a/test/intents.e2e-spec.ts +++ b/test/intents.e2e-spec.ts @@ -340,17 +340,18 @@ describe("IntentsController (e2e)", () => { expect(res.body.srcToken.priceUSD).toBe(1.0); }); - it("POST /api/v1/intents create with unknown srcToken address still succeeds (priceUSD undefined)", async () => { + it("POST /api/v1/intents create rejects an unregistered srcToken address (#276)", async () => { + // Previously this silently created an intent with priceUSD undefined; #276 + // tightens it to a clean 400 so an unknown asset can't enter the system. const res = await request(app.getHttpServer()) .post("/api/v1/intents") .send({ ...validCreateBody, - srcTokenAddress: "0xunknowntoken000000000000000000000000000", + srcTokenAddress: "0x1111111111111111111111111111111111111111", }) - .expect(201); + .expect(400); - // priceUSD should be undefined (not found in registry) - expect(res.body.srcToken.priceUSD).toBeUndefined(); + expect(res.body.error).toBeDefined(); }); it("POST /api/v1/intents/quote includes route.steps in each quote (#220)", async () => { @@ -463,4 +464,38 @@ describe("IntentsController (e2e)", () => { .get(`/api/v1/intents/${created.intentId}/quote`) .expect(404); }); + + // ── #275: batch intent-status lookup ───────────────────────────────────── + + it("POST /api/v1/intents/batch returns the record for each found ID and omits unknown ones", async () => { + const a = await createIntent(); + const b = await createIntent(); + + const res = await request(app.getHttpServer()) + .post("/api/v1/intents/batch") + .send({ intentIds: [a.intentId, b.intentId, "does-not-exist"] }) + .expect(201); + + expect(res.body.count).toBe(2); + const ids = res.body.intents.map((i: { intentId: string }) => i.intentId).sort(); + expect(ids).toEqual([a.intentId, b.intentId].sort()); + }); + + it("POST /api/v1/intents/batch returns an empty list when nothing matches", async () => { + const res = await request(app.getHttpServer()) + .post("/api/v1/intents/batch") + .send({ intentIds: ["nope-1", "nope-2"] }) + .expect(201); + + expect(res.body).toEqual({ intents: [], count: 0 }); + }); + + it("POST /api/v1/intents/batch rejects a list larger than 100 IDs with 400", async () => { + const res = await request(app.getHttpServer()) + .post("/api/v1/intents/batch") + .send({ intentIds: Array.from({ length: 101 }, (_, i) => `id-${i}`) }) + .expect(400); + + expect(res.body.error).toBeDefined(); + }); }); diff --git a/test/validation-negative-paths.e2e-spec.ts b/test/validation-negative-paths.e2e-spec.ts index 1a23e05..0ff312a 100644 --- a/test/validation-negative-paths.e2e-spec.ts +++ b/test/validation-negative-paths.e2e-spec.ts @@ -104,6 +104,55 @@ describe("Validation Negative Paths (e2e)", () => { }); }); + describe("List filter enum validation (#270)", () => { + it("should return 400 for an unknown state filter", async () => { + const res = await request(app.getHttpServer()) + .get("/api/v1/intents") + .query({ state: "bogus", limit: 20, offset: 0 }) + .expect(400); + expect(res.body.error).toBeDefined(); + }); + + it("should return 400 for an unknown chain filter", async () => { + const res = await request(app.getHttpServer()) + .get("/api/v1/intents") + .query({ chain: "notachain", limit: 20, offset: 0 }) + .expect(400); + expect(res.body.error).toBeDefined(); + }); + + it("should still accept a valid state filter", async () => { + await request(app.getHttpServer()) + .get("/api/v1/intents") + .query({ state: "open", limit: 20, offset: 0 }) + .expect(200); + }); + }); + + describe("Unknown destination/source token rejection (#276)", () => { + it("should return 400 for a well-formed but unregistered dstTokenContract", async () => { + const res = await request(app.getHttpServer()) + .post("/api/v1/intents") + .send({ + ...validCreateBody, + dstTokenContract: "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMZZZZZZ", + }) + .expect(400); + expect(res.body.error).toBeDefined(); + }); + + it("should return 400 for a well-formed but unregistered srcTokenAddress on a known chain", async () => { + const res = await request(app.getHttpServer()) + .post("/api/v1/intents") + .send({ + ...validCreateBody, + srcTokenAddress: "0x1111111111111111111111111111111111111111", + }) + .expect(400); + expect(res.body.error).toBeDefined(); + }); + }); + describe("Deadline validation", () => { it("should return 400 for deadline in the past", async () => { const pastTimestamp = Math.floor(Date.now() / 1000) - 3600; // 1 hour ago