diff --git a/src/__tests__/routes/accounts.test.ts b/src/__tests__/routes/accounts.test.ts new file mode 100644 index 00000000..3491976e --- /dev/null +++ b/src/__tests__/routes/accounts.test.ts @@ -0,0 +1,99 @@ +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; + +describe("Accounts route handlers", () => { + const app = createApp(); + + describe("GET /accounts/:address/balance", () => { + const ALICE = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; + const CONTRACT_A = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM"; + + beforeEach(() => { + mockQueryBalances.mockReset(); + }); + + 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({ + contractId: CONTRACT_A, + balance: "50000000", + displayBalance: "5.0000000", + }); + expect(res.body.derivedFromLedger).toBe(true); + }); + + it("returns the raw stroop amount alongside the display value", async () => { + // Returning only the display string would force every consumer to parse + // a decimal back to an integer to do arithmetic on it, guessing the + // scale on the way. + mockQueryBalances.mockResolvedValue([{ contractId: CONTRACT_A, balance: "1" }]); + + const res = await request(app).get(`/accounts/${ALICE}/balance`); + + expect(res.body.balances[0].balance).toBe("1"); + expect(res.body.balances[0].displayBalance).toBe("0.0000001"); + }); + + it("returns an empty balances array for an 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("says the figure is derived, not read from chain", async () => { + // Part of the contract, not decoration: this is a sum over the indexed + // window, so it reads low for an address that held tokens before the + // start ledger. A caller that mistakes it for an on-chain balance read + // will be wrong in a way the numbers themselves do not reveal. + mockQueryBalances.mockResolvedValue([]); + + const res = await request(app).get(`/accounts/${ALICE}/balance`); + + expect(res.body).toHaveProperty("derivedFromLedger", true); + expect(res.body.note).toMatch(/not read from chain/i); + }); + + it("scopes the query to the selected network", async () => { + // Summing two chains' transfers for one address produces a figure that + // corresponds to no balance anywhere. + mockQueryBalances.mockResolvedValue([]); + + const res = await request(app) + .get(`/accounts/${ALICE}/balance`) + .query({ network: "testnet" }); + + expect(res.status).toBe(200); + expect(mockQueryBalances).toHaveBeenCalledWith(ALICE, "testnet"); + expect(res.body.network).toBe("testnet"); + }); + + it("rejects a network this deployment does not serve, without querying", async () => { + const res = await request(app) + .get(`/accounts/${ALICE}/balance`) + .query({ network: "mainnet" }); + + expect(res.status).toBe(400); + expect(mockQueryBalances).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/api/accounts.ts b/src/api/accounts.ts index 4701dca8..db98acb7 100644 --- a/src/api/accounts.ts +++ b/src/api/accounts.ts @@ -1,5 +1,5 @@ import { Router, Request, Response, NextFunction } from "express"; -import { getAccountSummary } from "../db"; +import { getAccountSummary, queryBalances } from "../db"; import { toDisplayAmount } from "../api"; import { createAccountsTransfersRouter } from "../routes/accounts/transfers"; import { parseOr400 } from "../openapi/validation"; @@ -28,6 +28,43 @@ export function createAccountsRouter(): Router { router.use("/:address/transfers", createAccountsTransfersRouter()); + // ── GET /accounts/:address/balance ───────────────────────────────────────── + /** + * Per-token balance for an address, derived from indexed transfers. + * + * `derivedFromLedger` and the note are part of the contract, not decoration: + * this is a sum over the indexed window, so an address that held a token + * before the indexer's start ledger reads low, and one that was net-negative + * over that window reads negative. A caller that mistakes this for an + * on-chain balance read will be wrong in a way the numbers do not reveal. + */ + router.get( + "/:address/balance", + async (req: Request, res: Response, next: NextFunction) => { + try { + const { address } = req.params; + const network = requestNetwork(req); + const rows = await queryBalances(address, network); + + res.json({ + address, + network, + balances: rows.map((row) => ({ + contractId: row.contractId, + balance: row.balance, + displayBalance: toDisplayAmount(row.balance), + })), + derivedFromLedger: true, + note: + "Derived by summing indexed transfers, not read from chain. Excludes " + + "any history before the indexer's start ledger.", + }); + } catch (err) { + next(err); + } + } + ); + // ── GET /accounts/:address/summary ───────────────────────────────────────── router.get( "/:address/summary", diff --git a/src/db.ts b/src/db.ts index 6422ecd2..9b0e7221 100644 --- a/src/db.ts +++ b/src/db.ts @@ -231,6 +231,52 @@ export async function setLastIndexedLedger(ledger: number, network?: Network): P ); } +// ─── Derived balances ───────────────────────────────────────────────────────── +export type BalanceRow = { + contractId: string; + balance: string; +}; + +/** + * Per-token balance for an address, derived by summing what it received and + * subtracting what it sent across the indexed history. + * + * This is a *derived* figure, not an on-chain balance read. It is only correct + * from the ledger the indexer started at: anything the address held before + * that is invisible here, so the number can be lower than reality and, for an + * address that was net-negative over the indexed window, can even be negative. + * The route says so in its response rather than presenting it as authoritative. + * + * Scoped by network — summing two chains' transfers for the same address + * produces a figure that corresponds to no balance anywhere. + */ +export async function queryBalances( + address: string, + network?: Network +): Promise { + const net = resolveNetwork(network); + + return observeDbQuery("queryBalances", () => + prisma.$queryRaw` + SELECT + "contractId", + ( + COALESCE(SUM(CASE WHEN "toAddress" = ${address} THEN CAST("amount" AS NUMERIC) ELSE 0 END), 0) - + COALESCE(SUM(CASE WHEN "fromAddress" = ${address} THEN CAST("amount" AS NUMERIC) ELSE 0 END), 0) + )::TEXT AS "balance" + FROM "wraith"."TokenTransfer" + WHERE "network" = ${net} + AND ("toAddress" = ${address} OR "fromAddress" = ${address}) + GROUP BY "contractId" + HAVING ( + COALESCE(SUM(CASE WHEN "toAddress" = ${address} THEN CAST("amount" AS NUMERIC) ELSE 0 END), 0) - + COALESCE(SUM(CASE WHEN "fromAddress" = ${address} THEN CAST("amount" AS NUMERIC) ELSE 0 END), 0) + ) <> 0 + ORDER BY "contractId" + ` + ); +} + // ─── Backfill cursor helpers ─────────────────────────────────────────────── export interface BackfillCursorState { startLedger: number;