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
28 changes: 26 additions & 2 deletions src/routes/prices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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),
Expand All @@ -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,
Expand Down
64 changes: 62 additions & 2 deletions tests/routes/prices.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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);
});
});
});