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
4 changes: 2 additions & 2 deletions src/common/http-exception.filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ export class HttpExceptionFilter implements ExceptionFilter {
return;
}

// Already custom-shaped bodies passed directly to an exception constructor,
// Custom-shaped bodies passed directly to an exception constructor,
// e.g. new BadRequestException({ error: "...", fillAmount, minDstAmount })
if (typeof b.error === "string" && typeof b.message !== "string") {
if (typeof b.error === "string" && !b.statusCode) {
response.status(status).json(b);
return;
}
Expand Down
5 changes: 5 additions & 0 deletions src/intents/dto/create-intent.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,9 @@ export class CreateIntentDto {
@IsOptional()
@IsInt()
deadline?: number;

@ApiPropertyOptional({ description: "Idempotency key for deduplicating duplicate requests" })
@IsOptional()
@IsString()
idempotencyKey?: string;
}
31 changes: 28 additions & 3 deletions src/intents/intents.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,14 @@ import {
Post,
Query,
} from "@nestjs/common";
import { ApiTags } from "@nestjs/swagger";
import {
ApiTags,
ApiNotFoundResponse,
ApiConflictResponse,
ApiForbiddenResponse,
ApiGoneResponse,
ApiBadRequestResponse,
} from "@nestjs/swagger";
import { IntentsService } from "./intents.service";
import { IntentsGateway } from "./intents.gateway";
import { SolversService } from "../solvers/solvers.service";
Expand All @@ -31,6 +38,7 @@ export class IntentsController {
) {}

@Get()
@ApiBadRequestResponse({ description: "Invalid limit or offset" })
list(
@Query("state") state?: string,
@Query("user") user?: string,
Expand All @@ -44,7 +52,10 @@ export class IntentsController {
if (user) intents = intents.filter((i) => i.user.toLowerCase() === user.toLowerCase());
if (chain) intents = intents.filter((i) => i.srcChain === chain);

const limit = Math.min(parseInt(limitRaw, 10), 100);
const limit = parseInt(limitRaw, 10);
if (limit > 100) {
throw new BadRequestException("Limit exceeds maximum allowed value of 100");
}
const offset = parseInt(offsetRaw, 10);
const page = intents.slice(offset, offset + limit);

Expand All @@ -64,13 +75,15 @@ export class IntentsController {
}

@Get(":id")
@ApiNotFoundResponse({ description: "Intent not found" })
getOne(@Param("id") id: string) {
const intent = this.intentsService.get(id);
if (!intent) throw new NotFoundException("Intent not found");
return intent;
}

@Post()
@ApiBadRequestResponse({ description: "Invalid request body" })
create(@Body() dto: CreateIntentDto) {
const now = Math.floor(Date.now() / 1000);
const intent = this.intentsService.create({
Expand All @@ -91,12 +104,16 @@ export class IntentsController {
},
minDstAmount: dto.minDstAmount,
deadline: dto.deadline ?? now + 1800,
});
}, dto.idempotencyKey);
this.intentsGateway.broadcast({ type: "intent_created", intent });
return intent;
}

@Post(":id/accept")
@ApiNotFoundResponse({ description: "Intent not found" })
@ApiConflictResponse({ description: "Intent is not in open state" })
@ApiGoneResponse({ description: "Intent has expired" })
@ApiForbiddenResponse({ description: "Solver not registered or inactive" })
accept(@Param("id") id: string, @Body() dto: AcceptIntentDto) {
const intent = this.intentsService.get(id);
if (!intent) throw new NotFoundException("Intent not found");
Expand Down Expand Up @@ -125,6 +142,11 @@ export class IntentsController {
}

@Post(":id/fill")
@ApiNotFoundResponse({ description: "Intent not found" })
@ApiConflictResponse({ description: "Intent is not in accepted state" })
@ApiForbiddenResponse({ description: "Wrong solver for this intent" })
@ApiGoneResponse({ description: "Fill window has expired" })
@ApiBadRequestResponse({ description: "Fill amount below minimum" })
fill(@Param("id") id: string, @Body() dto: FillIntentDto) {
const intent = this.intentsService.get(id);
if (!intent) throw new NotFoundException("Intent not found");
Expand Down Expand Up @@ -166,6 +188,9 @@ export class IntentsController {
}

@Post(":id/cancel")
@ApiNotFoundResponse({ description: "Intent not found" })
@ApiForbiddenResponse({ description: "Unauthorized" })
@ApiConflictResponse({ description: "Intent is not in open state" })
cancel(@Param("id") id: string, @Body() dto: CancelIntentDto) {
const intent = this.intentsService.get(id);
if (!intent) throw new NotFoundException("Intent not found");
Expand Down
21 changes: 20 additions & 1 deletion src/intents/intents.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,26 @@ import { buildSeedIntents } from "./intents.seed";
@Injectable()
export class IntentsService {
private readonly intents = new Map<string, Intent>();
private readonly idempotencyCache = new Map<string, { intentId: string; expiresAt: number }>();

constructor() {
this.seed();
}

create(data: Omit<Intent, "intentId" | "createdAt" | "state">): Intent {
create(data: Omit<Intent, "intentId" | "createdAt" | "state">, idempotencyKey?: string): Intent {
const now = Math.floor(Date.now() / 1000);

if (idempotencyKey) {
const cached = this.idempotencyCache.get(idempotencyKey);
if (cached && cached.expiresAt > now) {
const cachedIntent = this.intents.get(cached.intentId);
if (cachedIntent) {
return cachedIntent;
}
}
this.idempotencyCache.delete(idempotencyKey);
}

const intent: Intent = {
...data,
intentId: uuidv4(),
Expand All @@ -21,6 +34,12 @@ export class IntentsService {
deadline: data.deadline ?? now + 1800,
};
this.intents.set(intent.intentId, intent);

if (idempotencyKey) {
const ttl = 86400; // 24 hours
this.idempotencyCache.set(idempotencyKey, { intentId: intent.intentId, expiresAt: now + ttl });
}

return intent;
}

Expand Down
26 changes: 26 additions & 0 deletions test/intents.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ describe("IntentsController (e2e)", () => {
expect(res.body.limit).toBe(2);
});

it("GET /api/v1/intents with limit > 100 returns 400", async () => {
const res = await request(app.getHttpServer())
.get("/api/v1/intents")
.query({ limit: 500 })
.expect(400);
expect(res.body.error).toBe("Limit exceeds maximum allowed value of 100");
});

it("GET /api/v1/intents/open returns only open intents", async () => {
const res = await request(app.getHttpServer()).get("/api/v1/intents/open").expect(200);
expect(res.body.intents.every((i: { state: string }) => i.state === "open")).toBe(true);
Expand Down Expand Up @@ -164,4 +172,22 @@ describe("IntentsController (e2e)", () => {
}
expect(res.body.bestQuote.dstAmount).toBe(res.body.quotes[0].dstAmount);
});

it("POST /api/v1/intents with idempotencyKey deduplicates requests", async () => {
const idempotencyKey = "test-key-" + Date.now();
const createBody = { ...validCreateBody, user: "GIDEMPOTENCY1234567", idempotencyKey };

const res1 = await request(app.getHttpServer())
.post("/api/v1/intents")
.send(createBody)
.expect(201);

const res2 = await request(app.getHttpServer())
.post("/api/v1/intents")
.send(createBody)
.expect(201);

expect(res1.body.intentId).toBe(res2.body.intentId);
expect(res1.body.createdAt).toBe(res2.body.createdAt);
});
});