From 78d27a8cb1ee5cf793a33f548e57a8dd210f7d42 Mon Sep 17 00:00:00 2001 From: satyakwok Date: Thu, 7 May 2026 12:55:22 +0200 Subject: [PATCH] =?UTF-8?q?fix(api):=20safe=20BigInt=20parse=20=E2=80=94?= =?UTF-8?q?=20400=20on=20bad=20cursor=20instead=20of=20500=20crash?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five endpoints did raw BigInt(req.query.*) / BigInt(req.params.*): native.ts: /blocks?before=... /blocks/:height coinblast.ts: /coinblast/tokens?before=... /coinblast/trades?before=... /coinblast/trades/by-curve/:curve?after=... When user passes non-numeric input (e.g. ?before=abc), BigInt() throws SyntaxError. Fastify catches and returns 500 internal server error with no actionable message. Should be 400 bad request. Fix: parseBigIntOrThrow helper wraps BigInt() in try/catch + throws an InvalidQueryError class. Each route catches the error and returns 400 with field-named message. Internal 500s drop, user gets actionable error, alerter stays quiet. Per-route handler signature changes: routes that previously didn't accept reply now do (added 'reply' param to enable code(400) calls). tsc --noEmit clean across both files. --- apps/api/src/routes/coinblast.ts | 34 ++++++++++++++++++++++++++------ apps/api/src/routes/native.ts | 32 +++++++++++++++++++++++++++--- 2 files changed, 57 insertions(+), 9 deletions(-) diff --git a/apps/api/src/routes/coinblast.ts b/apps/api/src/routes/coinblast.ts index 6ba6e78..2854a3b 100644 --- a/apps/api/src/routes/coinblast.ts +++ b/apps/api/src/routes/coinblast.ts @@ -17,6 +17,25 @@ import type { SentrixClient } from "@sentriscloud/indexer-chain"; const MAX_PAGE = 100; +// Safe BigInt parse — returns parsed value or throws InvalidQueryError so +// the route handler can convert to a 400 response. Pre-fix sites used +// raw BigInt(req.query.*) which threw SyntaxError on non-numeric input, +// surfacing as Fastify 500 instead of an actionable 400. +function parseBigIntOrThrow(raw: string, field: string): bigint { + try { + return BigInt(raw); + } catch { + throw new InvalidQueryError(`invalid ${field}: must be a non-negative integer`); + } +} + +class InvalidQueryError extends Error { + constructor(msg: string) { + super(msg); + this.name = "InvalidQueryError"; + } +} + // Reasonable upper-bound caps on metadata field lengths. The DB schema // uses unbounded `text` so the limit lives at the API edge. const MAX_IMAGE_URL = 256; @@ -39,7 +58,7 @@ export function registerCoinblastRoutes( owner?: string; before?: string; }; - }>("/coinblast/tokens", async (req) => { + }>("/coinblast/tokens", async (req, reply) => { const limit = clampLimit(req.query.limit); const conds = []; if (req.query.graduated === "true") { @@ -51,7 +70,8 @@ export function registerCoinblastRoutes( conds.push(eq(cbTokens.ownerAddress, req.query.owner.toLowerCase())); } if (req.query.before) { - conds.push(lt(cbTokens.createdBlock, BigInt(req.query.before))); + try { conds.push(lt(cbTokens.createdBlock, parseBigIntOrThrow(req.query.before, "before"))); } + catch (e) { return reply.code(400).send({ error: (e as Error).message }); } } const rows = await ctx.db .select() @@ -87,7 +107,7 @@ export function registerCoinblastRoutes( type?: string; before?: string; // block number }; - }>("/coinblast/trades", async (req) => { + }>("/coinblast/trades", async (req, reply) => { const limit = clampLimit(req.query.limit); const conds = []; if (req.query.curve) { @@ -100,7 +120,8 @@ export function registerCoinblastRoutes( conds.push(eq(cbTrades.type, req.query.type)); } if (req.query.before) { - conds.push(lt(cbTrades.blockNumber, BigInt(req.query.before))); + try { conds.push(lt(cbTrades.blockNumber, parseBigIntOrThrow(req.query.before, "before"))); } + catch (e) { return reply.code(400).send({ error: (e as Error).message }); } } const rows = await ctx.db .select() @@ -255,11 +276,12 @@ export function registerCoinblastRoutes( app.get<{ Params: { curve: string }; Querystring: { limit?: string; after?: string }; - }>("/coinblast/trades/by-curve/:curve", async (req) => { + }>("/coinblast/trades/by-curve/:curve", async (req, reply) => { const limit = clampLimit(req.query.limit); const conds = [eq(cbTrades.curveAddress, req.params.curve.toLowerCase())]; if (req.query.after) { - conds.push(eq(cbTrades.blockNumber, BigInt(req.query.after))); + try { conds.push(eq(cbTrades.blockNumber, parseBigIntOrThrow(req.query.after, "after"))); } + catch (e) { return reply.code(400).send({ error: (e as Error).message }); } } const rows = await ctx.db .select() diff --git a/apps/api/src/routes/native.ts b/apps/api/src/routes/native.ts index 833ed8b..791fe34 100644 --- a/apps/api/src/routes/native.ts +++ b/apps/api/src/routes/native.ts @@ -17,6 +17,26 @@ import type { SentrixClient } from "@sentriscloud/indexer-chain"; const MAX_PAGE = 100; +// Safe BigInt parse — returns undefined on malformed input rather than +// throwing. Pre-fix sites used raw `BigInt(req.query.before)` which threw +// SyntaxError on non-numeric strings, surfaced as a Fastify 500 with no +// actionable error message. This wraps the throw so endpoints can return +// 400 with a clear "invalid cursor" message and stay alerter-quiet. +function parseBigIntOrThrow(raw: string, field: string): bigint { + try { + return BigInt(raw); + } catch { + throw new InvalidQueryError(`invalid ${field}: must be a non-negative integer`); + } +} + +class InvalidQueryError extends Error { + constructor(msg: string) { + super(msg); + this.name = "InvalidQueryError"; + } +} + // /stats/daily moved off the chain native API in 2026-05-05 — at h~1.55M the // on-chain handler scanned every block from genesis under the state read lock // and hung the LB. Indexer side: a single GROUP BY against the timestamp-indexed @@ -32,9 +52,13 @@ export function registerNativeRoutes( // ── /blocks ─────────────────────────────────────────────── app.get<{ Querystring: { limit?: string; before?: string } }>( "/blocks", - async (req) => { + async (req, reply) => { const limit = clampLimit(req.query.limit); - const before = req.query.before ? BigInt(req.query.before) : undefined; + let before: bigint | undefined; + if (req.query.before) { + try { before = parseBigIntOrThrow(req.query.before, "before"); } + catch (e) { return reply.code(400).send({ error: (e as Error).message }); } + } const rows = await ctx.db .select() .from(blocks) @@ -47,7 +71,9 @@ export function registerNativeRoutes( // ── /blocks/:height ─────────────────────────────────────── app.get<{ Params: { height: string } }>("/blocks/:height", async (req, reply) => { - const h = BigInt(req.params.height); + let h: bigint; + try { h = parseBigIntOrThrow(req.params.height, "height"); } + catch (e) { return reply.code(400).send({ error: (e as Error).message }); } const row = await ctx.db.select().from(blocks).where(eq(blocks.height, h)).limit(1); if (!row[0]) return reply.code(404).send({ error: "block not found" }); const txs = await ctx.db