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
32 changes: 19 additions & 13 deletions src/routes/crank.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,32 @@
import { Hono } from "hono";
import { getSupabase, getNetwork, createLogger, truncateErrorMessage } from "@percolator/shared";
import { withDbCacheFallback } from "../middleware/db-cache-fallback.js";

const logger = createLogger("api:crank");

export function crankStatusRoutes(): Hono {
const app = new Hono();

app.get("/crank/status", async (c) => {
try {
const { data, error } = await getSupabase()
.from("markets_with_stats")
.select("slab_address, last_crank_slot, updated_at")
.eq("network", getNetwork())
.not("slab_address", "is", null);
if (error) throw error;
return c.json({ markets: data ?? [] });
} catch (err) {
logger.error("Error fetching crank status", {
error: truncateErrorMessage(err instanceof Error ? err.message : String(err), 120),
});
return c.json({ error: "Failed to fetch crank status" }, 500);
const result = await withDbCacheFallback(
"crank:status",
async () => {
const { data, error } = await getSupabase()
.from("markets_with_stats")
.select("slab_address, last_crank_slot, updated_at")
.eq("network", getNetwork())
.not("slab_address", "is", null);
if (error) throw error;
return data ?? [];
},
c
);

if (result instanceof Response) {
return result;
}

return c.json({ markets: result });
});

return app;
Expand Down
31 changes: 18 additions & 13 deletions src/routes/markets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,20 +80,25 @@ export function marketRoutes(): Hono {

// GET /markets/stats — all market stats from DB (filtered by network)
app.get("/markets/stats", async (c) => {
try {
const { data, error } = await getSupabase()
.from("markets_with_stats")
.select("slab_address, total_open_interest, total_accounts, last_crank_slot, last_price, mark_price, index_price, funding_rate, net_lp_pos, updated_at")
.eq("network", getNetwork())
.not("slab_address", "is", null);
if (error) throw error;
return c.json({ stats: data ?? [] });
} catch (err) {
logger.error("Error fetching all market stats", {
error: truncateErrorMessage(err instanceof Error ? err.message : String(err), 120),
});
return c.json({ error: "Failed to fetch market stats" }, 500);
const result = await withDbCacheFallback(
"markets:stats",
async () => {
const { data, error } = await getSupabase()
.from("markets_with_stats")
.select("slab_address, total_open_interest, total_accounts, last_crank_slot, last_price, mark_price, index_price, funding_rate, net_lp_pos, updated_at")
.eq("network", getNetwork())
.not("slab_address", "is", null);
if (error) throw error;
return data ?? [];
},
c
);

if (result instanceof Response) {
return result;
}

return c.json({ stats: result });
});

// GET /markets/:slab/stats — single market stats from DB
Expand Down
32 changes: 19 additions & 13 deletions src/routes/prices.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,33 @@
import { Hono } from "hono";
import { getSupabase, getNetwork, createLogger, truncateErrorMessage } from "@percolator/shared";
import { validateSlab } from "../middleware/validateSlab.js";
import { withDbCacheFallback } from "../middleware/db-cache-fallback.js";

const logger = createLogger("api:prices");

export function priceRoutes(): Hono {
const app = new Hono();

app.get("/prices/markets", async (c) => {
try {
const { data, error } = await getSupabase()
.from("markets_with_stats")
.select("slab_address, last_price, mark_price, index_price, updated_at")
.eq("network", getNetwork())
.not("slab_address", "is", null);
if (error) throw error;
return c.json({ markets: data ?? [] });
} catch (err) {
logger.error("Error fetching market prices", {
error: truncateErrorMessage(err instanceof Error ? err.message : String(err), 120),
});
return c.json({ error: "Failed to fetch prices" }, 500);
const result = await withDbCacheFallback(
"prices:markets",
async () => {
const { data, error } = await getSupabase()
.from("markets_with_stats")
.select("slab_address, last_price, mark_price, index_price, updated_at")
.eq("network", getNetwork())
.not("slab_address", "is", null);
if (error) throw error;
return data ?? [];
},
c
);

if (result instanceof Response) {
return result;
}

return c.json({ markets: result });
});

app.get("/prices/:slab", validateSlab, async (c) => {
Expand Down
129 changes: 67 additions & 62 deletions src/routes/stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
*/
import { Hono } from "hono";
import { getSupabase, getNetwork, createLogger, truncateErrorMessage } from "@percolator/shared";
import { withDbCacheFallback } from "../middleware/db-cache-fallback.js";

const logger = createLogger("api:stats");

Expand All @@ -31,69 +32,73 @@ export function statsRoutes(): Hono {
* }
*/
app.get("/stats", async (c) => {
try {
const network = getNetwork();

// Count total markets — filter by network to prevent devnet/mainnet mixing (PERC-8192)
const { count: marketsCount, error: marketsError } = await getSupabase()
.from("markets")
.select("*", { count: "exact", head: true })
.eq("network", network);

if (marketsError) throw marketsError;

// Aggregate stats from markets_with_stats view — filter by network to
// prevent cross-network volume/OI inflation in shared DB deployments.
const { data: stats, error: statsError } = await getSupabase()
.from("markets_with_stats")
.select("volume_24h, total_open_interest")
.eq("network", network)
.not("slab_address", "is", null);

if (statsError) throw statsError;

const safeBigInt = (val: unknown): bigint => {
try { return BigInt(val as string); } catch { return 0n; }
};
const volume24h = (stats ?? []).reduce((sum, s) => sum + safeBigInt(s.volume_24h ?? "0"), 0n);
const totalOI = (stats ?? []).reduce((sum, s) => sum + safeBigInt(s.total_open_interest ?? "0"), 0n);

// Count unique deployers — filter by network
const { data: deployers, error: deployersError } = await getSupabase()
.from("markets")
.select("deployer")
.eq("network", network);

if (deployersError) throw deployersError;

const uniqueDeployers = new Set((deployers ?? []).map((d) => d.deployer)).size;

// Count 24h trades — filter by network
// NOTE: trades table uses `created_at` (TIMESTAMPTZ), not `timestamp`.
// The `timestamp` column exists only on oracle_prices/funding_history/oi_history.
const since24h = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
const { count: trades24h, error: tradesError } = await getSupabase()
.from("trades")
.select("*", { count: "exact", head: true })
.eq("network", network)
.gte("created_at", since24h);

if (tradesError) throw tradesError;

return c.json({
totalMarkets: marketsCount ?? 0,
volume24h: volume24h.toString(),
totalOpenInterest: totalOI.toString(),
uniqueDeployers,
trades24h: trades24h ?? 0,
});
} catch (err) {
logger.error("Error fetching platform stats", { error: truncateErrorMessage(err instanceof Error ? err.message : String(err), 120) });
return c.json({
error: "Failed to fetch platform statistics",
...(process.env.NODE_ENV !== "production" && { details: err instanceof Error ? err.message : String(err) })
}, 500);
const result = await withDbCacheFallback(
"stats:platform",
async () => {
const network = getNetwork();

// Count total markets — filter by network to prevent devnet/mainnet mixing (PERC-8192)
const { count: marketsCount, error: marketsError } = await getSupabase()
.from("markets")
.select("*", { count: "exact", head: true })
.eq("network", network);

if (marketsError) throw marketsError;

// Aggregate stats from markets_with_stats view — filter by network to
// prevent cross-network volume/OI inflation in shared DB deployments.
const { data: stats, error: statsError } = await getSupabase()
.from("markets_with_stats")
.select("volume_24h, total_open_interest")
.eq("network", network)
.not("slab_address", "is", null);

if (statsError) throw statsError;

const safeBigInt = (val: unknown): bigint => {
try { return BigInt(val as string); } catch { return 0n; }
};
const volume24h = (stats ?? []).reduce((sum, s) => sum + safeBigInt(s.volume_24h ?? "0"), 0n);
const totalOI = (stats ?? []).reduce((sum, s) => sum + safeBigInt(s.total_open_interest ?? "0"), 0n);

// Count unique deployers — filter by network
const { data: deployers, error: deployersError } = await getSupabase()
.from("markets")
.select("deployer")
.eq("network", network);

if (deployersError) throw deployersError;

const uniqueDeployers = new Set((deployers ?? []).map((d) => d.deployer)).size;

// Count 24h trades — filter by network
// NOTE: trades table uses `created_at` (TIMESTAMPTZ), not `timestamp`.
// The `timestamp` column exists only on oracle_prices/funding_history/oi_history.
const since24h = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
const { count: trades24h, error: tradesError } = await getSupabase()
.from("trades")
.select("*", { count: "exact", head: true })
.eq("network", network)
.gte("created_at", since24h);

if (tradesError) throw tradesError;

return {
totalMarkets: marketsCount ?? 0,
volume24h: volume24h.toString(),
totalOpenInterest: totalOI.toString(),
uniqueDeployers,
trades24h: trades24h ?? 0,
};
},
c
);

if (result instanceof Response) {
return result;
}

return c.json(result);
});

return app;
Expand Down
9 changes: 6 additions & 3 deletions tests/routes/crank.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { crankStatusRoutes } from "../../src/routes/crank.js";
import { clearDbCache } from "../../src/middleware/db-cache-fallback.js";

// Mock @percolator/shared
vi.mock("@percolator/shared", () => ({
Expand Down Expand Up @@ -45,6 +46,7 @@ describe("crank routes", () => {

beforeEach(() => {
vi.clearAllMocks();
clearDbCache();

mockSupabase = {
from: vi.fn(() => chainable({ data: [], error: null })),
Expand Down Expand Up @@ -112,7 +114,7 @@ describe("crank routes", () => {
expect(data.markets[0].updated_at).toBeNull();
});

it("should handle database errors", async () => {
it("should handle database errors with 503 (no stale cache)", async () => {
mockSupabase.from.mockReturnValue(chainable({
data: null,
error: new Error("Database error"),
Expand All @@ -121,9 +123,10 @@ describe("crank routes", () => {
const app = crankStatusRoutes();
const res = await app.request("/crank/status");

expect(res.status).toBe(500);
// withDbCacheFallback returns 503 when DB fails and no stale cache is available
expect(res.status).toBe(503);
const data = await res.json();
expect(data.error).toBe("Failed to fetch crank status");
expect(data.error).toBe("Database temporarily unavailable");
});

it("should return all market stats fields", async () => {
Expand Down
19 changes: 11 additions & 8 deletions tests/routes/prices.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { priceRoutes } from "../../src/routes/prices.js";
import { clearDbCache } from "../../src/middleware/db-cache-fallback.js";

// Mock @percolator/shared
vi.mock("@percolator/shared", () => ({
Expand Down Expand Up @@ -30,6 +31,7 @@ describe("prices routes", () => {

beforeEach(() => {
vi.clearAllMocks();
clearDbCache();

mockSupabase = {
from: vi.fn(() => mockSupabase),
Expand Down Expand Up @@ -73,18 +75,19 @@ describe("prices routes", () => {
expect(data.markets[0].slab_address).toBe("11111111111111111111111111111111");
});

it("should handle database errors", async () => {
mockSupabase.not.mockResolvedValue({
data: null,
error: new Error("Database error")
it("should handle database errors with 503 (no stale cache)", async () => {
mockSupabase.not.mockResolvedValue({
data: null,
error: new Error("Database error")
});

const app = priceRoutes();
const res = await app.request("/prices/markets");

expect(res.status).toBe(500);
// withDbCacheFallback returns 503 when DB fails and no stale cache is available
expect(res.status).toBe(503);
const data = await res.json();
expect(data.error).toBe("Failed to fetch prices");
expect(data.error).toBe("Database temporarily unavailable");
});

it("should handle empty markets list", async () => {
Expand Down Expand Up @@ -122,8 +125,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
Loading
Loading