diff --git a/src/intents/intents.controller.ts b/src/intents/intents.controller.ts index 6d3296d..cdebe48 100644 --- a/src/intents/intents.controller.ts +++ b/src/intents/intents.controller.ts @@ -100,9 +100,6 @@ export class IntentsController { accept(@Param("id") id: string, @Body() dto: AcceptIntentDto) { const intent = this.intentsService.get(id); if (!intent) throw new NotFoundException("Intent not found"); - if (intent.state !== "open") { - throw new ConflictException(`Intent is ${intent.state}, cannot accept`); - } const now = Math.floor(Date.now() / 1000); if (intent.deadline <= now) { @@ -115,11 +112,12 @@ export class IntentsController { throw new ForbiddenException("Solver not registered or inactive"); } - const updated = this.intentsService.update(id, { - state: "accepted", - solver: dto.solver, - deadline: now + 300, // 5-minute fill window - }); + const updated = this.intentsService.acceptIfOpen(id, dto.solver); + if (!updated) { + const current = this.intentsService.get(id); + throw new ConflictException(`Intent is ${current?.state ?? "unknown"}, cannot accept`); + } + this.intentsGateway.broadcast({ type: "intent_accepted", intentId: id, solver: dto.solver }); return updated; } @@ -128,12 +126,6 @@ export class IntentsController { fill(@Param("id") id: string, @Body() dto: FillIntentDto) { const intent = this.intentsService.get(id); if (!intent) throw new NotFoundException("Intent not found"); - if (intent.state !== "accepted") { - throw new ConflictException(`Intent is ${intent.state}, cannot fill`); - } - if (intent.solver !== dto.solver) { - throw new ForbiddenException("Wrong solver for this intent"); - } const now = Math.floor(Date.now() / 1000); if (intent.deadline <= now) { @@ -150,12 +142,19 @@ export class IntentsController { }); } - const updated = this.intentsService.update(id, { - state: "filled", + const updated = this.intentsService.fillIfAccepted(id, dto.solver, { filledAt: now, fillAmount: dto.fillAmount, txHash: dto.txHash, }); + if (!updated) { + const current = this.intentsService.get(id); + if (current?.solver !== dto.solver) { + throw new ForbiddenException("Wrong solver for this intent"); + } + throw new ConflictException(`Intent is ${current?.state ?? "unknown"}, cannot fill`); + } + this.intentsGateway.broadcast({ type: "intent_filled", intentId: id, diff --git a/src/intents/intents.service.spec.ts b/src/intents/intents.service.spec.ts index 7bbba6d..f37e0fb 100644 --- a/src/intents/intents.service.spec.ts +++ b/src/intents/intents.service.spec.ts @@ -66,4 +66,78 @@ describe("IntentsService", () => { expect(intent.state).toBe("filled"); } }); + + describe("acceptIfOpen", () => { + it("transitions an open intent to accepted and returns it", () => { + const [open] = service.getByState("open"); + const result = service.acceptIfOpen(open.intentId, "SOLVER_X"); + + expect(result).not.toBeNull(); + expect(result!.state).toBe("accepted"); + expect(result!.solver).toBe("SOLVER_X"); + expect(service.get(open.intentId)!.state).toBe("accepted"); + }); + + it("returns null for a non-existent intent", () => { + expect(service.acceptIfOpen("does-not-exist", "SOLVER_X")).toBeNull(); + }); + + it("returns null when the intent is already accepted", () => { + const [accepted] = service.getByState("accepted"); + expect(service.acceptIfOpen(accepted.intentId, "SOLVER_X")).toBeNull(); + }); + + it("only the first caller wins under simulated concurrency", () => { + const [open] = service.getByState("open"); + const results = Array.from({ length: 10 }, (_, i) => + service.acceptIfOpen(open.intentId, `SOLVER_${i}`), + ); + + const successes = results.filter((r) => r !== null); + expect(successes).toHaveLength(1); + expect(successes[0]!.state).toBe("accepted"); + }); + }); + + describe("fillIfAccepted", () => { + it("transitions an accepted intent to filled when solver matches", () => { + const [accepted] = service.getByState("accepted"); + const result = service.fillIfAccepted(accepted.intentId, accepted.solver!, { + fillAmount: "100", + txHash: "test-hash", + filledAt: Math.floor(Date.now() / 1000), + }); + + expect(result).not.toBeNull(); + expect(result!.state).toBe("filled"); + expect(result!.fillAmount).toBe("100"); + }); + + it("returns null when solver does not match", () => { + const [accepted] = service.getByState("accepted"); + const result = service.fillIfAccepted(accepted.intentId, "WRONG_SOLVER", { + fillAmount: "100", + }); + expect(result).toBeNull(); + }); + + it("returns null for a non-existent intent", () => { + expect(service.fillIfAccepted("nope", "SOLVER_X", {})).toBeNull(); + }); + + it("only the first caller wins under simulated concurrency", () => { + const [accepted] = service.getByState("accepted"); + const results = Array.from({ length: 10 }, () => + service.fillIfAccepted(accepted.intentId, accepted.solver!, { + fillAmount: "100", + txHash: "race-hash", + filledAt: Math.floor(Date.now() / 1000), + }), + ); + + const successes = results.filter((r) => r !== null); + expect(successes).toHaveLength(1); + expect(successes[0]!.state).toBe("filled"); + }); + }); }); diff --git a/src/intents/intents.service.ts b/src/intents/intents.service.ts index 86ed406..fe441b7 100644 --- a/src/intents/intents.service.ts +++ b/src/intents/intents.service.ts @@ -48,6 +48,41 @@ export class IntentsService { return updated; } + /** + * Atomically accept an intent only if it is currently "open". + * Mirrors the DB pattern: UPDATE intents SET state='accepted' WHERE id=$1 AND state='open' RETURNING * + * Returns null when the intent is not found or is not in the "open" state (already taken). + */ + acceptIfOpen(id: string, solver: string): Intent | null { + const existing = this.intents.get(id); + if (!existing || existing.state !== "open") return null; + + const now = Math.floor(Date.now() / 1000); + const updated: Intent = { + ...existing, + state: "accepted", + solver, + deadline: now + 300, + }; + this.intents.set(id, updated); + return updated; + } + + /** + * Atomically fill an intent only if it is currently "accepted" by the given solver. + * Mirrors the DB pattern: + * UPDATE intents SET state='filled', ... WHERE id=$1 AND state='accepted' AND solver=$2 RETURNING * + * Returns null when the intent is not found, not accepted, or assigned to a different solver. + */ + fillIfAccepted(id: string, solver: string, patch: Omit, "state" | "solver">): Intent | null { + const existing = this.intents.get(id); + if (!existing || existing.state !== "accepted" || existing.solver !== solver) return null; + + const updated: Intent = { ...existing, ...patch, state: "filled" }; + this.intents.set(id, updated); + return updated; + } + private seed() { const now = Math.floor(Date.now() / 1000); for (const data of buildSeedIntents(now)) { diff --git a/test/__mocks__/@stellar/stellar-sdk.ts b/test/__mocks__/@stellar/stellar-sdk.ts new file mode 100644 index 0000000..8e37039 --- /dev/null +++ b/test/__mocks__/@stellar/stellar-sdk.ts @@ -0,0 +1,10 @@ +const mockServer = { + getHealth: jest.fn().mockResolvedValue({ status: "ok" }), + getLatestLedger: jest.fn().mockResolvedValue({ sequence: 1 }), + getNetwork: jest.fn().mockResolvedValue({ passphrase: "test" }), + getAccount: jest.fn().mockResolvedValue({ id: "test", sequence: "0" }), +}; + +export const SorobanRpc = { + Server: jest.fn().mockImplementation(() => mockServer), +}; diff --git a/test/jest-e2e.json b/test/jest-e2e.json index d79db1e..910ad19 100644 --- a/test/jest-e2e.json +++ b/test/jest-e2e.json @@ -2,8 +2,11 @@ "moduleFileExtensions": ["js", "json", "ts"], "rootDir": "..", "testEnvironment": "node", - "testRegex": "test/.*\\.e2e-spec\\.ts$", + "testRegex": "test/.*\\.(e2e-spec|test)\\.ts$", "transform": { - "^.+\\.ts$": "ts-jest" + "^.+\\.ts$": ["ts-jest", { "diagnostics": false }] + }, + "moduleNameMapper": { + "^@stellar/stellar-sdk$": "/test/__mocks__/@stellar/stellar-sdk.ts" } } diff --git a/test/load/concurrent-accept.test.ts b/test/load/concurrent-accept.test.ts new file mode 100644 index 0000000..16de8b6 --- /dev/null +++ b/test/load/concurrent-accept.test.ts @@ -0,0 +1,160 @@ +import { INestApplication } from "@nestjs/common"; +import request from "supertest"; +import { createTestApp } from "../utils/create-test-app"; +import { IntentsService } from "../../src/intents/intents.service"; + +const SOLVERS = ["SOLVER_ALPHA", "SOLVER_BETA", "SOLVER_GAMMA"]; + +const validCreateBody = { + user: "GRACETESTUSER1234567", + srcChain: "ethereum", + srcTokenAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + srcTokenSymbol: "USDC", + srcTokenDecimals: 6, + srcAmount: "1000000", + dstTokenContract: "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA", + dstTokenSymbol: "USDC", + dstTokenDecimals: 7, + minDstAmount: "990000", +}; + +describe("Concurrent accept / fill race load test", () => { + let app: INestApplication; + + beforeAll(async () => { + app = await createTestApp(); + }); + + afterAll(async () => { + await app.close(); + }); + + async function createIntent(): Promise { + const res = await request(app.getHttpServer()) + .post("/api/v1/intents") + .send(validCreateBody) + .expect(201); + return res.body.intentId as string; + } + + it("only one solver wins when N concurrent accept() calls race on the same intent", async () => { + const intentId = await createIntent(); + const concurrency = 20; + + const results = await Promise.allSettled( + Array.from({ length: concurrency }, (_, i) => { + const solver = SOLVERS[i % SOLVERS.length]; + return request(app.getHttpServer()) + .post(`/api/v1/intents/${intentId}/accept`) + .send({ solver }); + }), + ); + + const fulfilled = results.filter( + (r): r is PromiseFulfilledResult => r.status === "fulfilled", + ); + + const successes = fulfilled.filter((r) => r.value.status === 201); + const rejected = results.filter((r) => r.status === "rejected"); + + expect(successes).toHaveLength(1); + + const intent = (await request(app.getHttpServer()).get(`/api/v1/intents/${intentId}`).expect(200)) + .body; + expect(intent.state).toBe("accepted"); + expect(SOLVERS).toContain(intent.solver); + }); + + it("only one solver wins when N concurrent fill() calls race on the same accepted intent", async () => { + const intentId = await createIntent(); + + await request(app.getHttpServer()) + .post(`/api/v1/intents/${intentId}/accept`) + .send({ solver: "SOLVER_ALPHA" }) + .expect(201); + + const concurrency = 20; + + const results = await Promise.allSettled( + Array.from({ length: concurrency }, () => + request(app.getHttpServer()) + .post(`/api/v1/intents/${intentId}/fill`) + .send({ solver: "SOLVER_ALPHA", fillAmount: "995000", txHash: `tx-${Math.random()}` }), + ), + ); + + const fulfilled = results.filter( + (r): r is PromiseFulfilledResult => r.status === "fulfilled", + ); + + const successes = fulfilled.filter((r) => r.value.status === 201); + + expect(successes).toHaveLength(1); + + const intent = (await request(app.getHttpServer()).get(`/api/v1/intents/${intentId}`).expect(200)) + .body; + expect(intent.state).toBe("filled"); + expect(intent.fillAmount).toBe("995000"); + }); + + it("mixed solvers racing for different intents all resolve with at most one winner each", async () => { + const concurrency = 3; + const intentIds: string[] = []; + for (let i = 0; i < concurrency; i++) { + intentIds.push(await createIntent()); + } + + const results = await Promise.allSettled( + intentIds.map((id, i) => { + const solver = SOLVERS[i % SOLVERS.length]; + return request(app.getHttpServer()) + .post(`/api/v1/intents/${id}/accept`) + .send({ solver }); + }), + ); + + const fulfilled = results.filter( + (r): r is PromiseFulfilledResult => r.status === "fulfilled", + ); + const successes = fulfilled.filter((r) => r.value.status === 201); + expect(successes.length).toBeLessThanOrEqual(concurrency); + + for (const id of intentIds) { + const intent = (await request(app.getHttpServer()).get(`/api/v1/intents/${id}`).expect(200)) + .body; + expect(intent.state).toBe("accepted"); + } + }); + + it("unit-level: acceptIfOpen rejects concurrent calls on the same intent", () => { + const service = new IntentsService(); + const [open] = service.getByState("open"); + + const results = Array.from({ length: 50 }, (_, i) => + service.acceptIfOpen(open.intentId, `SOLVER_${i}`), + ); + + const winners = results.filter((r) => r !== null); + expect(winners).toHaveLength(1); + expect(winners[0]!.state).toBe("accepted"); + }); + + it("unit-level: fillIfAccepted rejects concurrent calls on the same intent", () => { + const service = new IntentsService(); + const [open] = service.getByState("open"); + + service.acceptIfOpen(open.intentId, "SOLVER_X"); + + const results = Array.from({ length: 50 }, () => + service.fillIfAccepted(open.intentId, "SOLVER_X", { + fillAmount: "995000", + txHash: "race-hash", + filledAt: Math.floor(Date.now() / 1000), + }), + ); + + const winners = results.filter((r) => r !== null); + expect(winners).toHaveLength(1); + expect(winners[0]!.state).toBe("filled"); + }); +});