Skip to content
Open
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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
36 changes: 31 additions & 5 deletions docs/runbooks/on-call.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

---

Expand Down Expand Up @@ -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 <pid>
# In Kubernetes:
# kubectl exec <pod> -- 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

Expand Down
25 changes: 25 additions & 0 deletions src/intents/dto/batch-lookup.dto.ts
Original file line number Diff line number Diff line change
@@ -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[];
}
26 changes: 19 additions & 7 deletions src/intents/dto/list-intents.dto.ts
Original file line number Diff line number Diff line change
@@ -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()
Expand Down
68 changes: 68 additions & 0 deletions src/intents/intents-batch-lookup.spec.ts
Original file line number Diff line number Diff line change
@@ -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<AppConfig, true>;
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);
});
});
67 changes: 67 additions & 0 deletions src/intents/intents-sweeper.manual-trigger.spec.ts
Original file line number Diff line number Diff line change
@@ -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"));
});
});
43 changes: 42 additions & 1 deletion src/intents/intents-sweeper.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -30,10 +37,11 @@ export class IntentsSweeperService implements OnModuleInit, OnModuleDestroy {
if (this.interval) clearInterval(this.interval);
}

async sweep() {
async sweep(): Promise<SweepResult> {
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) {
Expand Down Expand Up @@ -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<SweepResult> {
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;
}
}

Expand Down
Loading