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
31 changes: 15 additions & 16 deletions src/intents/intents.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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;
}
Expand All @@ -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) {
Expand All @@ -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,
Expand Down
74 changes: 74 additions & 0 deletions src/intents/intents.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
});
35 changes: 35 additions & 0 deletions src/intents/intents.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Partial<Intent>, "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)) {
Expand Down
10 changes: 10 additions & 0 deletions test/__mocks__/@stellar/stellar-sdk.ts
Original file line number Diff line number Diff line change
@@ -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),
};
7 changes: 5 additions & 2 deletions test/jest-e2e.json
Original file line number Diff line number Diff line change
Expand Up @@ -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$": "<rootDir>/test/__mocks__/@stellar/stellar-sdk.ts"
}
}
160 changes: 160 additions & 0 deletions test/load/concurrent-accept.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<request.Response> => 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<request.Response> => 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<request.Response> => 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");
});
});