Skip to content

Commit cfbaf72

Browse files
committed
Add GET /accounts/:address/balance, network-scoped and schema-qualified
Derived per-token balances for an address, summing what it received and subtracting what it sent across the indexed history. Rebased onto main, which has moved a long way since this branch: the token cache landed via #46 and the metrics module via #175, so those parts of this PR are dropped as duplicates and what remains is the balance endpoint itself. Three fixes to the query on the way in: - The table reference was unqualified, `FROM "TokenTransfer"`. Every other raw query in db.ts uses `"wraith"."TokenTransfer"`, because the models declare @@Schema("wraith") — unqualified it resolves only if search_path happens to include the schema, so it would work locally and fail on a deployment that sets search_path differently. - No network predicate. Summing both chains' transfers for one address gives a number that corresponds to no balance anywhere. Now takes the network and filters on it, with the route reading it from the selector so an unknown network 400s instead of silently answering for the default. - The metrics timer was started and stopped around the query but not in a finally, so a throw leaked it. Uses observeDbQuery, which times failures too — a query that takes eight seconds and then fails is the one worth seeing. Mounted on the existing accounts router rather than a second one, so it sits beside /summary and /transfers and inherits the network middleware. The response keeps this PR's honesty about what the number is — a sum over the indexed window, not an on-chain read — and returns both the raw stroop amount and the display string, so a consumer doing arithmetic does not have to parse the decimal back and guess the scale. tsc clean; full suite 402 passed.
1 parent a06b5d3 commit cfbaf72

3 files changed

Lines changed: 140 additions & 10 deletions

File tree

src/__tests__/routes/accounts.test.ts

Lines changed: 56 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,23 +18,40 @@ describe("Accounts route handlers", () => {
1818
const ALICE = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF";
1919
const CONTRACT_A = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM";
2020

21+
beforeEach(() => {
22+
mockQueryBalances.mockReset();
23+
});
24+
2125
it("returns per-token derived balance for a known address", async () => {
2226
mockQueryBalances.mockResolvedValue([
23-
{ contractId: CONTRACT_A, balance: "50000000" } // 5.0000000
27+
{ contractId: CONTRACT_A, balance: "50000000" }, // 5.0000000
2428
]);
2529

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

2832
expect(res.status).toBe(200);
2933
expect(res.body.balances).toHaveLength(1);
3034
expect(res.body.balances[0]).toEqual({
31-
token: CONTRACT_A,
32-
balance: "5.0000000"
35+
contractId: CONTRACT_A,
36+
balance: "50000000",
37+
displayBalance: "5.0000000",
3338
});
34-
expect(res.body.derived_from_ledger).toBe(true);
39+
expect(res.body.derivedFromLedger).toBe(true);
40+
});
41+
42+
it("returns the raw stroop amount alongside the display value", async () => {
43+
// Returning only the display string would force every consumer to parse
44+
// a decimal back to an integer to do arithmetic on it, guessing the
45+
// scale on the way.
46+
mockQueryBalances.mockResolvedValue([{ contractId: CONTRACT_A, balance: "1" }]);
47+
48+
const res = await request(app).get(`/accounts/${ALICE}/balance`);
49+
50+
expect(res.body.balances[0].balance).toBe("1");
51+
expect(res.body.balances[0].displayBalance).toBe("0.0000001");
3552
});
3653

37-
it("returns empty balances array for unknown address", async () => {
54+
it("returns an empty balances array for an unknown address", async () => {
3855
mockQueryBalances.mockResolvedValue([]);
3956

4057
const res = await request(app).get(`/accounts/GUNKNOWN/balance`);
@@ -43,10 +60,40 @@ describe("Accounts route handlers", () => {
4360
expect(res.body.balances).toHaveLength(0);
4461
});
4562

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);
63+
it("says the figure is derived, not read from chain", async () => {
64+
// Part of the contract, not decoration: this is a sum over the indexed
65+
// window, so it reads low for an address that held tokens before the
66+
// start ledger. A caller that mistakes it for an on-chain balance read
67+
// will be wrong in a way the numbers themselves do not reveal.
68+
mockQueryBalances.mockResolvedValue([]);
69+
70+
const res = await request(app).get(`/accounts/${ALICE}/balance`);
71+
72+
expect(res.body).toHaveProperty("derivedFromLedger", true);
73+
expect(res.body.note).toMatch(/not read from chain/i);
74+
});
75+
76+
it("scopes the query to the selected network", async () => {
77+
// Summing two chains' transfers for one address produces a figure that
78+
// corresponds to no balance anywhere.
79+
mockQueryBalances.mockResolvedValue([]);
80+
81+
const res = await request(app)
82+
.get(`/accounts/${ALICE}/balance`)
83+
.query({ network: "testnet" });
84+
85+
expect(res.status).toBe(200);
86+
expect(mockQueryBalances).toHaveBeenCalledWith(ALICE, "testnet");
87+
expect(res.body.network).toBe("testnet");
88+
});
89+
90+
it("rejects a network this deployment does not serve, without querying", async () => {
91+
const res = await request(app)
92+
.get(`/accounts/${ALICE}/balance`)
93+
.query({ network: "mainnet" });
94+
95+
expect(res.status).toBe(400);
96+
expect(mockQueryBalances).not.toHaveBeenCalled();
5097
});
5198
});
5299
});

src/api/accounts.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Router, Request, Response, NextFunction } from "express";
2-
import { getAccountSummary } from "../db";
2+
import { getAccountSummary, queryBalances } from "../db";
33
import { toDisplayAmount } from "../api";
44
import { createAccountsTransfersRouter } from "../routes/accounts/transfers";
55
import { parseOr400 } from "../openapi/validation";
@@ -28,6 +28,43 @@ export function createAccountsRouter(): Router {
2828

2929
router.use("/:address/transfers", createAccountsTransfersRouter());
3030

31+
// ── GET /accounts/:address/balance ─────────────────────────────────────────
32+
/**
33+
* Per-token balance for an address, derived from indexed transfers.
34+
*
35+
* `derivedFromLedger` and the note are part of the contract, not decoration:
36+
* this is a sum over the indexed window, so an address that held a token
37+
* before the indexer's start ledger reads low, and one that was net-negative
38+
* over that window reads negative. A caller that mistakes this for an
39+
* on-chain balance read will be wrong in a way the numbers do not reveal.
40+
*/
41+
router.get(
42+
"/:address/balance",
43+
async (req: Request, res: Response, next: NextFunction) => {
44+
try {
45+
const { address } = req.params;
46+
const network = requestNetwork(req);
47+
const rows = await queryBalances(address, network);
48+
49+
res.json({
50+
address,
51+
network,
52+
balances: rows.map((row) => ({
53+
contractId: row.contractId,
54+
balance: row.balance,
55+
displayBalance: toDisplayAmount(row.balance),
56+
})),
57+
derivedFromLedger: true,
58+
note:
59+
"Derived by summing indexed transfers, not read from chain. Excludes " +
60+
"any history before the indexer's start ledger.",
61+
});
62+
} catch (err) {
63+
next(err);
64+
}
65+
}
66+
);
67+
3168
// ── GET /accounts/:address/summary ─────────────────────────────────────────
3269
router.get(
3370
"/:address/summary",

src/db.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,52 @@ export async function setLastIndexedLedger(ledger: number, network?: Network): P
231231
);
232232
}
233233

234+
// ─── Derived balances ─────────────────────────────────────────────────────────
235+
export type BalanceRow = {
236+
contractId: string;
237+
balance: string;
238+
};
239+
240+
/**
241+
* Per-token balance for an address, derived by summing what it received and
242+
* subtracting what it sent across the indexed history.
243+
*
244+
* This is a *derived* figure, not an on-chain balance read. It is only correct
245+
* from the ledger the indexer started at: anything the address held before
246+
* that is invisible here, so the number can be lower than reality and, for an
247+
* address that was net-negative over the indexed window, can even be negative.
248+
* The route says so in its response rather than presenting it as authoritative.
249+
*
250+
* Scoped by network — summing two chains' transfers for the same address
251+
* produces a figure that corresponds to no balance anywhere.
252+
*/
253+
export async function queryBalances(
254+
address: string,
255+
network?: Network
256+
): Promise<BalanceRow[]> {
257+
const net = resolveNetwork(network);
258+
259+
return observeDbQuery("queryBalances", () =>
260+
prisma.$queryRaw<BalanceRow[]>`
261+
SELECT
262+
"contractId",
263+
(
264+
COALESCE(SUM(CASE WHEN "toAddress" = ${address} THEN CAST("amount" AS NUMERIC) ELSE 0 END), 0) -
265+
COALESCE(SUM(CASE WHEN "fromAddress" = ${address} THEN CAST("amount" AS NUMERIC) ELSE 0 END), 0)
266+
)::TEXT AS "balance"
267+
FROM "wraith"."TokenTransfer"
268+
WHERE "network" = ${net}
269+
AND ("toAddress" = ${address} OR "fromAddress" = ${address})
270+
GROUP BY "contractId"
271+
HAVING (
272+
COALESCE(SUM(CASE WHEN "toAddress" = ${address} THEN CAST("amount" AS NUMERIC) ELSE 0 END), 0) -
273+
COALESCE(SUM(CASE WHEN "fromAddress" = ${address} THEN CAST("amount" AS NUMERIC) ELSE 0 END), 0)
274+
) <> 0
275+
ORDER BY "contractId"
276+
`
277+
);
278+
}
279+
234280
// ─── Backfill cursor helpers ───────────────────────────────────────────────
235281
export interface BackfillCursorState {
236282
startLedger: number;

0 commit comments

Comments
 (0)