From ad5f4c2364d9d5168f0003f0be89751b8c9d90fb Mon Sep 17 00:00:00 2001 From: changliuchang777 Date: Mon, 6 Jul 2026 02:08:40 +0800 Subject: [PATCH] feat(pricing): enforce quote amount bounds --- README.md | 14 ++++++ src/__tests__/index.test.ts | 89 +++++++++++++++++++++++++++++++++++++ src/index.ts | 89 +++++++++++++++++++++++++++++++++++-- 3 files changed, 189 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 0caac48..919eb37 100644 --- a/README.md +++ b/README.md @@ -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` diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index 22a553d..5aa45f8 100644 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -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(); diff --git a/src/index.ts b/src/index.ts index 97d3e92..ac0eee2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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. * @@ -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(); @@ -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))) { @@ -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(), }; @@ -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. @@ -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);