Skip to content

Commit 5809806

Browse files
committed
feat: implement accounts balance route with ledger-derived token balances
1 parent 493f028 commit 5809806

4 files changed

Lines changed: 118 additions & 16 deletions

File tree

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import request from "supertest";
2+
import { createApp } from "../../api";
3+
import { queryBalances } from "../../db";
4+
5+
// Mock the DB module
6+
jest.mock("../../db", () => ({
7+
...jest.requireActual("../../db"),
8+
queryBalances: jest.fn(),
9+
prisma: { $queryRaw: jest.fn() },
10+
}));
11+
12+
const mockQueryBalances = queryBalances as jest.MockedFunction<typeof queryBalances>;
13+
14+
describe("Accounts route handlers", () => {
15+
const app = createApp();
16+
17+
describe("GET /accounts/:address/balance", () => {
18+
const ALICE = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF";
19+
const CONTRACT_A = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM";
20+
21+
it("returns per-token derived balance for a known address", async () => {
22+
mockQueryBalances.mockResolvedValue([
23+
{ contractId: CONTRACT_A, balance: "50000000" } // 5.0000000
24+
]);
25+
26+
const res = await request(app).get(`/accounts/${ALICE}/balance`);
27+
28+
expect(res.status).toBe(200);
29+
expect(res.body.balances).toHaveLength(1);
30+
expect(res.body.balances[0]).toEqual({
31+
token: CONTRACT_A,
32+
balance: "5.0000000"
33+
});
34+
expect(res.body.derived_from_ledger).toBe(true);
35+
});
36+
37+
it("returns empty balances array for unknown address", async () => {
38+
mockQueryBalances.mockResolvedValue([]);
39+
40+
const res = await request(app).get(`/accounts/GUNKNOWN/balance`);
41+
42+
expect(res.status).toBe(200);
43+
expect(res.body.balances).toHaveLength(0);
44+
});
45+
46+
it("includes a derived_from_ledger field in the response", async () => {
47+
mockQueryBalances.mockResolvedValue([]);
48+
const res = await request(app).get(`/accounts/${ALICE}/balance`);
49+
expect(res.body).toHaveProperty("derived_from_ledger", true);
50+
});
51+
});
52+
});

src/api.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { createAccountsRouter } from "./api/accounts";
99
import { createWebhooksRouter } from "./api/webhooks";
1010
import { getAllCachedTokens } from "./tokenCache";
1111
import { register, priceRequestsTotal } from "./metrics";
12+
import accountsRouter from "./routes/accounts";
1213

1314
// ── Rate limiting ─────────────────────────────────────────────────────────────
1415
const limiter = rateLimit({
@@ -143,6 +144,9 @@ export function createApp(): express.Application {
143144
res.json({ ok: true, uptime: process.uptime() });
144145
});
145146

147+
// ── GET /accounts/:address/balance ──────────────────────────────────────────
148+
app.use("/accounts", accountsRouter);
149+
146150
// ── GET /readyz — K8s/Render readiness probe ─────────────────────────────────
147151
/**
148152
* Returns 200 only when:

src/db.ts

Lines changed: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -506,21 +506,9 @@ export async function getNftOwner(
506506

507507
// ─── Account summary helpers ──────────────────────────────────────────────────
508508

509-
/**
510-
* Incrementally update materialized aggregates for every address touched by
511-
* `records`. Called inside the same logical write as upsertTransfers so the
512-
* two tables never diverge.
513-
*
514-
* Strategy:
515-
* 1. Accumulate per-(address, contractId) deltas in memory.
516-
* 2. Emit one raw UPSERT per unique pair — O(unique addresses) DB round-trips.
517-
*
518-
* Using raw SQL because Prisma cannot do arithmetic on string-typed NUMERIC columns.
519-
*/
520509
export async function upsertAccountSummaries(records: TransferRecord[]): Promise<void> {
521510
if (records.length === 0) return;
522511

523-
// Accumulate deltas keyed by "address|contractId"
524512
const deltas = new Map<
525513
string,
526514
{ address: string; contractId: string; sent: bigint; received: bigint; count: number; lastAt: Date }
@@ -567,10 +555,6 @@ export async function upsertAccountSummaries(records: TransferRecord[]): Promise
567555
}
568556
}
569557

570-
/**
571-
* Return all asset rows for a given address, optionally filtered to one contract.
572-
* O(1) — reads directly from the materialized AccountSummary table.
573-
*/
574558
export async function getAccountSummary(address: string, contractId?: string) {
575559
return prisma.accountSummary.findMany({
576560
where: {
@@ -653,6 +637,36 @@ export async function queryAccountSummaries(params: AccountSummaryQueryParams) {
653637
};
654638
}
655639

640+
// ─── Balance aggregate query ──────────────────────────────────────────────────
641+
export type BalanceRow = {
642+
contractId: string;
643+
balance: string;
644+
};
645+
646+
export async function queryBalances(address: string): Promise<BalanceRow[]> {
647+
const end = dbQueryDurationSeconds.startTimer({ operation: "queryBalances" });
648+
649+
const rows = await prisma.$queryRaw<BalanceRow[]>`
650+
SELECT
651+
"contractId",
652+
(
653+
COALESCE(SUM(CASE WHEN "toAddress" = ${address} THEN CAST("amount" AS NUMERIC) ELSE 0 END), 0) -
654+
COALESCE(SUM(CASE WHEN "fromAddress" = ${address} THEN CAST("amount" AS NUMERIC) ELSE 0 END), 0)
655+
)::TEXT AS "balance"
656+
FROM "TokenTransfer"
657+
WHERE "toAddress" = ${address} OR "fromAddress" = ${address}
658+
GROUP BY "contractId"
659+
HAVING (
660+
COALESCE(SUM(CASE WHEN "toAddress" = ${address} THEN CAST("amount" AS NUMERIC) ELSE 0 END), 0) -
661+
COALESCE(SUM(CASE WHEN "fromAddress" = ${address} THEN CAST("amount" AS NUMERIC) ELSE 0 END), 0)
662+
) != 0
663+
ORDER BY "contractId"
664+
`;
665+
666+
end();
667+
return rows;
668+
}
669+
656670
// ─── Combined address query ───────────────────────────────────────────────────
657671
export type AllTransfersQueryParams = {
658672
address: string;

src/routes/accounts.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { Router, Request, Response, NextFunction } from "express";
2+
import { queryBalances } from "../db";
3+
import { toDisplayAmount } from "../api";
4+
5+
const router = Router();
6+
7+
/**
8+
* GET /accounts/:address/balance
9+
* Returns per-token derived balances for an address by summing incoming
10+
* transfers and subtracting outgoing ones from the indexed history.
11+
*/
12+
router.get("/:address/balance", async (req: Request, res: Response, next: NextFunction) => {
13+
try {
14+
const { address } = req.params;
15+
const rows = await queryBalances(address);
16+
17+
const balances = rows.map((row) => ({
18+
token: row.contractId,
19+
balance: toDisplayAmount(row.balance),
20+
}));
21+
22+
res.json({
23+
balances,
24+
derived_from_ledger: true,
25+
note: "This balance is derived from indexed token transfers and may not include pre-indexer history.",
26+
});
27+
} catch (err) {
28+
next(err);
29+
}
30+
});
31+
32+
export default router;

0 commit comments

Comments
 (0)