Skip to content
99 changes: 99 additions & 0 deletions src/__tests__/routes/accounts.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof queryBalances>;

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();
});
});
});
39 changes: 38 additions & 1 deletion src/api/accounts.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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",
Expand Down
46 changes: 46 additions & 0 deletions src/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<BalanceRow[]> {
const net = resolveNetwork(network);

return observeDbQuery("queryBalances", () =>
prisma.$queryRaw<BalanceRow[]>`
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;
Expand Down
Loading