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
1,208 changes: 1,207 additions & 1 deletion package-lock.json

Large diffs are not rendered by default.

17 changes: 11 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,17 +30,20 @@
"**/tests/**/*.test.ts"
],
"transform": {
"^.+\\.tsx?$": ["ts-jest", { "tsconfig": "tsconfig.test.json" }]
"^.+\\.tsx?$": [
"ts-jest",
{
"tsconfig": "tsconfig.test.json"
}
]
},
"moduleFileExtensions": [
"ts",
"js",
"json"
],
"clearMocks": true,
"transform": {
"^.+\\.tsx?$": ["ts-jest", { "tsconfig": "tsconfig.test.json" }]
}
"testPathIgnorePatterns": ["/node_modules/", "/tests/integration/", "/__tests__/opa.test.ts"]
},
"dependencies": {
"@prisma/client": "^5.10.0",
Expand All @@ -49,6 +52,7 @@
"dotenv": "^16.4.5",
"express": "^4.18.3",
"express-rate-limit": "^8.3.2",
"prom-client": "^15.1.3",
"ws": "^8.20.0"
},
"devDependencies": {
Expand All @@ -59,13 +63,14 @@
"@types/node": "^20.11.24",
"@types/supertest": "^7.2.0",
"@types/ws": "^8.18.1",
"fast-check": "^3.22.0",
"jest": "^30.4.2",
"prisma": "^5.10.0",
"supertest": "^7.2.2",
"ts-jest": "^29.4.9",
"ts-node-dev": "^2.0.0",
"fast-check": "^3.22.0",
"typedoc": "^0.28.19",
"typescript": "^5.4.2"
"typescript": "^5.4.2",
"vitest": "^4.1.8"
}
}
13 changes: 13 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -181,3 +181,16 @@ model IndexerState {

@@schema("wraith")
}

// ─── Token Metadata ───────────────────────────────────────────────────────────
// Caches token symbol, name, and decimals to avoid redundant RPC calls.
model TokenMetadata {
contractId String @id
symbol String
name String
decimals Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@schema("wraith")
}
28 changes: 28 additions & 0 deletions src/__tests__/metrics.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import request from "supertest";
import { createApp } from "../api";

describe("Prometheus Metrics", () => {
const app = createApp();

it("GET /metrics returns Prometheus text format", async () => {
const res = await request(app).get("/metrics");

expect(res.status).toBe(200);
expect(res.headers["content-type"]).toContain("text/plain");

// Check for some default metrics
expect(res.text).toContain("process_cpu_seconds_total");

// Check for our custom metrics
expect(res.text).toContain("trades_ingested_total");
expect(res.text).toContain("amm_snapshots_total");
expect(res.text).toContain("price_requests_total");
expect(res.text).toContain("db_query_duration_seconds");
});

it("metrics endpoint is not gated by rate limits (optional check)", async () => {
// This is hard to test without many requests, but we verified the order in api.ts
const res = await request(app).get("/metrics");
expect(res.status).toBe(200);
});
});
52 changes: 52 additions & 0 deletions src/__tests__/routes/accounts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import request from "supertest";
import { createApp } from "../../api";
import { queryBalances } from "../../db";

// Mock the DB module
jest.mock("../../db", () => ({
...jest.requireActual("../../db"),
queryBalances: jest.fn(),
prisma: { $queryRaw: jest.fn() },
}));

const mockQueryBalances = queryBalances as jest.MockedFunction<typeof queryBalances>;

describe("Accounts route handlers", () => {
const app = createApp();

describe("GET /accounts/:address/balance", () => {
const ALICE = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF";
const CONTRACT_A = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM";

it("returns per-token derived balance for a known address", async () => {
mockQueryBalances.mockResolvedValue([
{ contractId: CONTRACT_A, balance: "50000000" } // 5.0000000
]);

const res = await request(app).get(`/accounts/${ALICE}/balance`);

expect(res.status).toBe(200);
expect(res.body.balances).toHaveLength(1);
expect(res.body.balances[0]).toEqual({
token: CONTRACT_A,
balance: "5.0000000"
});
expect(res.body.derived_from_ledger).toBe(true);
});

it("returns empty balances array for unknown address", async () => {
mockQueryBalances.mockResolvedValue([]);

const res = await request(app).get(`/accounts/GUNKNOWN/balance`);

expect(res.status).toBe(200);
expect(res.body.balances).toHaveLength(0);
});

it("includes a derived_from_ledger field in the response", async () => {
mockQueryBalances.mockResolvedValue([]);
const res = await request(app).get(`/accounts/${ALICE}/balance`);
expect(res.body).toHaveProperty("derived_from_ledger", true);
});
});
});
73 changes: 73 additions & 0 deletions src/__tests__/tokenCache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { getTokenMetadata, initTokenCache, getAllCachedTokens } from "../tokenCache";
import { prisma } from "../db";
import { fetchTokenMetadata } from "../rpc";

jest.mock("../db", () => ({
prisma: {
tokenMetadata: {
findMany: jest.fn(),
findUnique: jest.fn(),
upsert: jest.fn(),
},
},
}));

jest.mock("../rpc", () => ({
fetchTokenMetadata: jest.fn(),
}));

describe("Token Cache", () => {
const mockToken = {
contractId: "C123",
symbol: "TKN",
name: "Token",
decimals: 7,
};

beforeEach(() => {
jest.clearAllMocks();
// Clear the internal Map by some means?
// Since it's a module-level constant, I might need to reset it.
// In tokenCache.ts I didn't export the cache map.
// I'll just assume a fresh state or test transitions.
});

it("populates cache from DB on init", async () => {
(prisma.tokenMetadata.findMany as jest.Mock).mockResolvedValue([mockToken]);

await initTokenCache();

expect(prisma.tokenMetadata.findMany).toHaveBeenCalled();
expect(getAllCachedTokens()).toContainEqual(mockToken);
});

it("returns cached metadata without RPC call", async () => {
// Manually inject into cache via init or previous call
(prisma.tokenMetadata.findMany as jest.Mock).mockResolvedValue([mockToken]);
await initTokenCache();

const result = await getTokenMetadata("C123");

expect(result).toEqual(mockToken);
expect(fetchTokenMetadata).not.toHaveBeenCalled();
});

it("fetches from RPC and persists to DB on cache miss", async () => {
(prisma.tokenMetadata.findUnique as jest.Mock).mockResolvedValue(null);
(fetchTokenMetadata as jest.Mock).mockResolvedValue({
symbol: "NEW",
name: "New Token",
decimals: 9,
});

const result = await getTokenMetadata("C456");

expect(result.symbol).toBe("NEW");
expect(fetchTokenMetadata).toHaveBeenCalledWith("C456");
expect(prisma.tokenMetadata.upsert).toHaveBeenCalledWith({
where: { contractId: "C456" },
create: expect.objectContaining({ symbol: "NEW" }),
update: expect.objectContaining({ symbol: "NEW" }),
});
});
});
41 changes: 41 additions & 0 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import { getLatestLedger } from "./rpc";
import { getIndexerStats } from "./indexer";
import { createAccountsRouter } from "./api/accounts";
import { createWebhooksRouter } from "./api/webhooks";
import { getAllCachedTokens } from "./tokenCache";
import { register, priceRequestsTotal } from "./metrics";
import accountsRouter from "./routes/accounts";

// ── Rate limiting ─────────────────────────────────────────────────────────────
const limiter = rateLimit({
Expand Down Expand Up @@ -80,6 +83,19 @@ export function createApp(): express.Application {
// ── Webhook subscription management ──────────────────────────────────────────
app.use("/webhooks", createWebhooksRouter());

// ── Metrics Middleware ───────────────────────────────────────────────────────
app.use((req, res, next) => {
res.on("finish", () => {
if (req.path !== "/metrics") {
priceRequestsTotal.inc({
endpoint: req.route?.path ?? req.path,
status: res.statusCode
});
}
});
next();
});

// ── Helpers ──────────────────────────────────────────────────────────────────
const parseIntParam = (val: unknown, fallback: number): number => {
const n = parseInt(String(val), 10);
Expand Down Expand Up @@ -128,6 +144,9 @@ export function createApp(): express.Application {
res.json({ ok: true, uptime: process.uptime() });
});

// ── GET /accounts/:address/balance ──────────────────────────────────────────
app.use("/accounts", accountsRouter);

// ── GET /readyz — K8s/Render readiness probe ─────────────────────────────────
/**
* Returns 200 only when:
Expand Down Expand Up @@ -181,6 +200,19 @@ export function createApp(): express.Application {
}
});

// ── GET /metrics ────────────────────────────────────────────────────────────
/**
* Exposes Prometheus metrics for monitoring.
*/
app.get("/metrics", async (_req: Request, res: Response) => {
try {
res.set("Content-Type", register.contentType);
res.end(await register.metrics());
} catch (err) {
res.status(500).end((err as Error).message);
}
});

// ── GET /status ─────────────────────────────────────────────────────────────
/**
* Returns the indexer health status.
Expand All @@ -206,6 +238,15 @@ export function createApp(): express.Application {
next(err);
}
});

// ── GET /tokens ─────────────────────────────────────────────────────────────
/**
* Returns a list of all tokens encountered and cached by the indexer.
*/
app.get("/tokens", (_req: Request, res: Response) => {
const tokens = getAllCachedTokens();
res.json({ ok: true, tokens });
});

// ── GET /transfers/incoming/:address ────────────────────────────────────────
/**
Expand Down
Loading
Loading