From 93956a0ee343d80b35b63e8bd208fed7852f1125 Mon Sep 17 00:00:00 2001 From: 6figpsolseeker <6figpsolseeker@gmail.com> Date: Thu, 9 Apr 2026 02:06:57 -0400 Subject: [PATCH] fix(prices): sanitize price values from oracle_prices and markets_with_stats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both /prices/markets and /prices/:slab returned raw rows from Supabase without validating numeric price fields. A bad row in the upstream indexer (negative, NaN, Infinity, or absurdly large) would propagate straight to the chart consumers, where lightweight-charts silently fails on a single corrupt point in an ordered series. This mirrors the sanitization already applied in src/routes/markets.ts (Number.isFinite + > 0 + <= 1_000_000_000 USD bound), with a parallel 1e15 bound for price_e6 (microUSD). Two strategies, depending on consumer expectations: - /prices/markets returns one record per market — bad fields are nulled so the rest of the row stays intact. - /prices/:slab feeds an ordered chart series — bad rows are dropped entirely so consumers never see a hole or a NaN. Also updates two pre-existing stale assertions in tests/routes/prices.test.ts that asserted ascending: false / limit(100) — the source has shipped ascending: true / limit(1500) for some time and the comment at prices.ts:30-37 explains why. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/routes/prices.ts | 28 ++++++++++++++-- tests/routes/prices.test.ts | 64 +++++++++++++++++++++++++++++++++++-- 2 files changed, 88 insertions(+), 4 deletions(-) diff --git a/src/routes/prices.ts b/src/routes/prices.ts index 2c666ee..ffa0817 100644 --- a/src/routes/prices.ts +++ b/src/routes/prices.ts @@ -7,6 +7,16 @@ const logger = createLogger("api:prices"); export function priceRoutes(): Hono { const app = new Hono(); + // Sanity bound for USD-denominated prices, mirroring src/routes/markets.ts. + // Bad rows in markets_with_stats / oracle_prices (negative, NaN, absurd) must + // not reach the chart consumers — lightweight-charts silently fails on them. + const MAX_SANE_PRICE_USD = 1_000_000_000; + // price_e6 is the same value scaled by 1e6 (microUSD), so its bound is 1e15. + const MAX_SANE_PRICE_E6 = MAX_SANE_PRICE_USD * 1_000_000; + + const sanitizeUsdPrice = (v: unknown): number | null => + typeof v === "number" && Number.isFinite(v) && v > 0 && v <= MAX_SANE_PRICE_USD ? v : null; + app.get("/prices/markets", async (c) => { try { const { data, error } = await getSupabase() @@ -15,7 +25,14 @@ export function priceRoutes(): Hono { .eq("network", getNetwork()) .not("slab_address", "is", null); if (error) throw error; - return c.json({ markets: data ?? [] }); + const markets = (data ?? []).map((m) => ({ + slab_address: m.slab_address, + last_price: sanitizeUsdPrice(m.last_price), + mark_price: sanitizeUsdPrice(m.mark_price), + index_price: sanitizeUsdPrice(m.index_price), + updated_at: m.updated_at, + })); + return c.json({ markets }); } catch (err) { logger.error("Error fetching market prices", { error: truncateErrorMessage(err instanceof Error ? err.message : String(err), 120), @@ -42,7 +59,14 @@ export function priceRoutes(): Hono { .order("timestamp", { ascending: true }) .limit(1500); if (error) throw error; - return c.json({ prices: data ?? [] }); + // Drop rows whose price_e6 is not a positive finite number within the + // sanity bound. Charts feed this series straight to lightweight-charts' + // setData(), which silently fails on a single corrupt row. + const prices = (data ?? []).filter((p: { price_e6?: unknown }) => { + const v = p.price_e6; + return typeof v === "number" && Number.isFinite(v) && v > 0 && v <= MAX_SANE_PRICE_E6; + }); + return c.json({ prices }); } catch (err) { logger.error("Error fetching price history", { slab, diff --git a/tests/routes/prices.test.ts b/tests/routes/prices.test.ts index ac30a5e..d92d213 100644 --- a/tests/routes/prices.test.ts +++ b/tests/routes/prices.test.ts @@ -97,6 +97,43 @@ describe("prices routes", () => { const data = await res.json(); expect(data.markets).toHaveLength(0); }); + + it("should null out invalid price fields (NaN, negative, zero, absurd)", async () => { + const mockMarkets = [ + { + slab_address: "11111111111111111111111111111111", + last_price: -5, // negative → null + mark_price: 0, // zero → null + index_price: 50000000000, // valid (clamped under 1e9)... wait this is > 1e9 + updated_at: "2025-01-01T00:00:00Z", + }, + { + slab_address: "22222222222222222222222222222222", + last_price: Number.NaN, // NaN → null + mark_price: Number.POSITIVE_INFINITY, // Infinity → null + index_price: 250.5, // valid + updated_at: "2025-01-01T00:00:00Z", + }, + ]; + + mockSupabase.not.mockResolvedValue({ data: mockMarkets, error: null }); + + const app = priceRoutes(); + const res = await app.request("/prices/markets"); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.markets[0].last_price).toBeNull(); + expect(data.markets[0].mark_price).toBeNull(); + // 50000000000 > 1_000_000_000 → out of sane USD bound → null + expect(data.markets[0].index_price).toBeNull(); + expect(data.markets[1].last_price).toBeNull(); + expect(data.markets[1].mark_price).toBeNull(); + expect(data.markets[1].index_price).toBe(250.5); + // Slab and timestamp should still be returned + expect(data.markets[0].slab_address).toBe("11111111111111111111111111111111"); + expect(data.markets[0].updated_at).toBe("2025-01-01T00:00:00Z"); + }); }); describe("GET /prices/:slab", () => { @@ -122,8 +159,8 @@ describe("prices routes", () => { expect(res.status).toBe(200); const data = await res.json(); expect(data.prices).toHaveLength(2); - expect(mockSupabase.order).toHaveBeenCalledWith("timestamp", { ascending: false }); - expect(mockSupabase.limit).toHaveBeenCalledWith(100); + expect(mockSupabase.order).toHaveBeenCalledWith("timestamp", { ascending: true }); + expect(mockSupabase.limit).toHaveBeenCalledWith(1500); }); it("should return 400 for invalid slab", async () => { @@ -159,5 +196,28 @@ describe("prices routes", () => { const data = await res.json(); expect(data.prices).toHaveLength(0); }); + + it("should drop rows with invalid price_e6 (negative, NaN, zero, absurd)", async () => { + const mockPrices = [ + { slab_address: "11111111111111111111111111111111", price_e6: 50000000000, timestamp: "2025-01-01T00:00:00Z" }, // valid ($50k) + { slab_address: "11111111111111111111111111111111", price_e6: -1, timestamp: "2025-01-01T00:01:00Z" }, // negative + { slab_address: "11111111111111111111111111111111", price_e6: 0, timestamp: "2025-01-01T00:02:00Z" }, // zero + { slab_address: "11111111111111111111111111111111", price_e6: Number.NaN, timestamp: "2025-01-01T00:03:00Z" }, // NaN + { slab_address: "11111111111111111111111111111111", price_e6: 50100000000, timestamp: "2025-01-01T00:04:00Z" }, // valid + { slab_address: "11111111111111111111111111111111", price_e6: null, timestamp: "2025-01-01T00:05:00Z" }, // missing + { slab_address: "11111111111111111111111111111111", price_e6: 1e16, timestamp: "2025-01-01T00:06:00Z" }, // > 1e15 cap + ]; + + mockSupabase.limit.mockResolvedValue({ data: mockPrices, error: null }); + + const app = priceRoutes(); + const res = await app.request("/prices/11111111111111111111111111111111"); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.prices).toHaveLength(2); + expect(data.prices[0].price_e6).toBe(50000000000); + expect(data.prices[1].price_e6).toBe(50100000000); + }); }); });