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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,20 @@ succeed; every other mutating write is rejected with `503 read_only_mode` using
the canonical error body. `paused` is strictly stronger: when the service is
paused, the existing pause behavior (`503 service_paused`) wins.

## Quote amount bounds

Registered pairs can carry `minAmount`, `maxAmount`, and `liquidity` metadata
through the pair metadata PATCH endpoints. Quote handlers compare the parsed
base-unit amount with those values using `BigInt`; the string value `"0"` means
that bound is unset.

- `GET /api/v1/quote` returns `400 invalid_request` when `amount < minAmount`
or `amount > maxAmount`.
- `GET /api/v1/quote` returns `422 insufficient_liquidity` when `amount`
exceeds non-zero `liquidity`.
- `POST /api/v1/quote/bulk` keeps processing the batch and reports bound
failures per item as `{ index, ok: false, error: "out_of_bounds" }`.

## OpenAPI spec

The OpenAPI document is the single source of truth in `src/openapi.ts`
Expand Down
89 changes: 89 additions & 0 deletions src/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1148,6 +1148,95 @@ describe("StableRoute Backend", () => {
});
});

describe("quote amount bounds", () => {
beforeEach(() => {
resetStores();
});

it("rejects GET quotes below minAmount with a canonical 400", async () => {
await request(app).post("/api/v1/pairs").send({ source: "MIN", destination: "DST" });
await request(app).patch("/api/v1/pairs/MIN/DST/min").send({ minAmount: "100" });

const res = await request(app)
.get("/api/v1/quote")
.set("X-Request-Id", "bounds-min")
.query({ source_asset: "MIN", dest_asset: "DST", amount: "99" });

expect(res.status).toBe(400);
expectCanonicalError(res.body, "bounds-min", "invalid_request");
expect(res.body.message).toMatch(/below minAmount/);
});

it("rejects GET quotes above maxAmount with a canonical 400", async () => {
await request(app).post("/api/v1/pairs").send({ source: "MAX", destination: "DST" });
await request(app).patch("/api/v1/pairs/MAX/DST/max").send({ maxAmount: "1000" });

const res = await request(app)
.get("/api/v1/quote")
.set("X-Request-Id", "bounds-max")
.query({ source_asset: "MAX", dest_asset: "DST", amount: "1001" });

expect(res.status).toBe(400);
expectCanonicalError(res.body, "bounds-max", "invalid_request");
expect(res.body.message).toMatch(/exceeds maxAmount/);
});

it("rejects GET quotes above liquidity with insufficient_liquidity", async () => {
await request(app).post("/api/v1/pairs").send({ source: "LIQ", destination: "DST" });
await request(app).patch("/api/v1/pairs/LIQ/DST/liquidity").send({ liquidity: "500" });

const res = await request(app)
.get("/api/v1/quote")
.set("X-Request-Id", "bounds-liq")
.query({ source_asset: "LIQ", dest_asset: "DST", amount: "501" });

expect(res.status).toBe(422);
expectCanonicalError(res.body, "bounds-liq", "insufficient_liquidity");
expect(res.body.message).toMatch(/available liquidity/);
});

it("allows amounts exactly at min, max, and liquidity bounds", async () => {
await request(app).post("/api/v1/pairs").send({ source: "EDGE", destination: "DST" });
await request(app).patch("/api/v1/pairs/EDGE/DST/min").send({ minAmount: "100" });
await request(app).patch("/api/v1/pairs/EDGE/DST/max").send({ maxAmount: "1000" });
await request(app).patch("/api/v1/pairs/EDGE/DST/liquidity").send({ liquidity: "1000" });

const min = await request(app)
.get("/api/v1/quote")
.query({ source_asset: "EDGE", dest_asset: "DST", amount: "100" });
expect(min.status).toBe(200);
expect(min.body.amount).toBe("100");

const maxAndLiquidity = await request(app)
.get("/api/v1/quote")
.query({ source_asset: "EDGE", dest_asset: "DST", amount: "1000" });
expect(maxAndLiquidity.status).toBe(200);
expect(maxAndLiquidity.body.amount).toBe("1000");
});

it("reports bulk quote bound failures per item without failing the batch", async () => {
await request(app).post("/api/v1/pairs").send({ source: "BULK", destination: "DST" });
await request(app).patch("/api/v1/pairs/BULK/DST/min").send({ minAmount: "100" });
await request(app).patch("/api/v1/pairs/BULK/DST/max").send({ maxAmount: "1000" });
await request(app).patch("/api/v1/pairs/BULK/DST/liquidity").send({ liquidity: "1000" });

const res = await request(app)
.post("/api/v1/quote/bulk")
.send({
items: [
{ source_asset: "BULK", dest_asset: "DST", amount: "100" },
{ source_asset: "BULK", dest_asset: "DST", amount: "99" },
{ source_asset: "BULK", dest_asset: "DST", amount: "1001" },
],
});

expect(res.status).toBe(200);
expect(res.body.results[0]).toMatchObject({ index: 0, ok: true, amount: "100" });
expect(res.body.results[1]).toMatchObject({ index: 1, ok: false, error: "out_of_bounds" });
expect(res.body.results[2]).toMatchObject({ index: 2, ok: false, error: "out_of_bounds" });
});
});

describe("GET /api/v1/events — type filter", () => {
beforeEach(() => {
resetStores();
Expand Down
89 changes: 86 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1791,6 +1791,13 @@ const parseAmount = (v: unknown): bigint | null => {
}
};

const parseSlippageBps = (v: unknown): number | null => {
if (v === undefined) return 0;
if (typeof v !== "string" || !/^[0-9]{1,4}$/.test(v)) return null;
const parsed = Number(v);
return Number.isInteger(parsed) && parsed >= 0 && parsed <= 1000 ? parsed : null;
};

/**
* Compute the fee breakdown for a given amount and fee rate.
*
Expand Down Expand Up @@ -1828,6 +1835,56 @@ export const applyFee = (
return { feeAmount, netAmount };
};

type QuoteBoundsViolation = {
status: 400 | 422;
error: "invalid_request" | "insufficient_liquidity";
bulkError: "out_of_bounds";
message: string;
};

/**
* Validate a quote amount against per-pair min/max/liquidity metadata.
*
* The metadata value "0" means the bound is unset. All comparisons stay in
* BigInt space so large base-unit amounts are never coerced through Number.
*/
export const checkQuoteBounds = (
meta: PairMeta,
amount: bigint
): QuoteBoundsViolation | null => {
const minAmount = BigInt(meta.minAmount);
if (minAmount !== 0n && amount < minAmount) {
return {
status: 400,
error: "invalid_request",
bulkError: "out_of_bounds",
message: `amount (${amount}) is below minAmount (${minAmount})`,
};
}

const maxAmount = BigInt(meta.maxAmount);
if (maxAmount !== 0n && amount > maxAmount) {
return {
status: 400,
error: "invalid_request",
bulkError: "out_of_bounds",
message: `amount (${amount}) exceeds maxAmount (${maxAmount})`,
};
}

const liquidity = BigInt(meta.liquidity);
if (liquidity !== 0n && amount > liquidity) {
return {
status: 422,
error: "insufficient_liquidity",
bulkError: "out_of_bounds",
message: `amount (${amount}) exceeds available liquidity (${liquidity})`,
};
}

return null;
};

const computeQuoteTimes = (): { quoted_at: number; expires_at: number } => {
const ttl = (config.quote_ttl_ms ?? 30000) as number;
const quoted_at = Date.now();
Expand Down Expand Up @@ -1855,7 +1912,8 @@ app.post("/api/v1/quote/bulk", (req: Request, res: Response) => {
const { source_asset: rawSource, dest_asset: rawDest, amount } = it ?? {};
const source_asset = normalizeAsset(rawSource);
const dest_asset = normalizeAsset(rawDest);
if (source_asset === null || dest_asset === null || parseAmount(amount) === null || source_asset === dest_asset) {
const parsedAmount = parseAmount(amount);
if (source_asset === null || dest_asset === null || parsedAmount === null || source_asset === dest_asset) {
return { index: i, ok: false as const, error: "invalid_item" };
}
if (!allowUnregistered && !pairRegistry.has(pairKey(source_asset, dest_asset))) {
Expand All @@ -1866,13 +1924,30 @@ app.post("/api/v1/quote/bulk", (req: Request, res: Response) => {
if (pairRegistry.has(bulkKey) && bulkMeta.enabled === false) {
return { index: i, ok: false as const, error: "pair_disabled" };
}
if (pairRegistry.has(bulkKey)) {
const boundsViolation = checkQuoteBounds(bulkMeta, parsedAmount);
if (boundsViolation) {
return {
index: i,
ok: false as const,
error: boundsViolation.bulkError,
message: boundsViolation.message,
};
}
}
const slippage_bps = 0;
const { feeAmount, netAmount } = applyFee(parsedAmount, bulkMeta.feeBps);
const min_received = applySlippage(netAmount, slippage_bps);
return {
index: i,
ok: true as const,
source_asset,
dest_asset,
amount: String(amount),
estimated_rate: "1.0",
amount: parsedAmount.toString(),
estimated_rate: bulkMeta.rate,
feeBps: bulkMeta.feeBps,
feeAmount: feeAmount.toString(),
netAmount: netAmount.toString(),
slippage_bps,
min_received: min_received.toString(),
};
Expand Down Expand Up @@ -1916,6 +1991,10 @@ app.get("/api/v1/quote", (req: Request, res: Response) => {
"amount must be a positive integer string with no leading zero"
);
}
const slippage_bps = parseSlippageBps(rawSlippage);
if (slippage_bps === null) {
return sendError(res, req, 400, "invalid_request", "slippage_bps must be an integer in [0,1000]");
}

/**
* Pair registration guard.
Expand All @@ -1939,6 +2018,10 @@ app.get("/api/v1/quote", (req: Request, res: Response) => {
}

const meta = pairMeta.get(pairKey(source_asset, dest_asset)) ?? defaultMeta();
const boundsViolation = checkQuoteBounds(meta, parsedAmount);
if (boundsViolation) {
return sendError(res, req, boundsViolation.status, boundsViolation.error, boundsViolation.message);
}
const { feeAmount, netAmount } = applyFee(parsedAmount, meta.feeBps);
const min_received = applySlippage(netAmount, slippage_bps);

Expand Down