Skip to content
Merged
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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,14 @@ Base URL: `http://localhost:3000`

Offramp order lookups need a bearer token; see [docs/offramp-orders.md](docs/offramp-orders.md).

`GET /transfers.csv` and `/transfers.parquet` need no filter, and are **capped
rather than rejected**: a request with nothing narrowing it returns the first
`maxRows` rows instead of the whole table. When rows were left behind, the
response says so in `X-Truncated: true` and `X-Row-Limit: <n>`, set before the
body starts — so a client can tell a complete export from a partial one without
counting. Narrow with `address`, `contractId`, `fromLedger`/`toLedger` or
`fromDate`/`toDate`, or raise `maxRows` up to 500 000.

### Selecting a network

Wraith stores testnet and mainnet rows in the same tables, discriminated by a
Expand Down Expand Up @@ -625,6 +633,7 @@ curl "http://localhost:3000/transfers/tx/abcdef1234567890..."
| `RETENTION_DAYS` | `30` | Delete transfers older than N days (keeps DB within free-tier limits) |
| `NETWORKS` | *(`STELLAR_NETWORK`)* | Comma-separated networks to index in one process, e.g. `testnet,mainnet`. Also the set the API's `?network=` selector accepts. |
| `PORT` | `3000` | REST API port |
| `EXPORT_MAX_ROWS` | `50000` | Default row cap for `/transfers.csv` and `/transfers.parquet`. Clamped to 500 000; a caller may ask for less with `?maxRows=`. |

### RPC URL Resolution

Expand Down
149 changes: 149 additions & 0 deletions src/__tests__/routes/exports.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import request from "supertest";

// ── Module mocks must be declared before any imports that use them ──────────
jest.mock("../../db", () => ({
queryTransfers: jest.fn(),
queryAllTransfers: jest.fn(),
queryByTxHash: jest.fn(),
querySummary: jest.fn(),
getLastIndexedLedger: jest.fn(),
toDisplayAmount: jest.fn((amount: string) => amount),
prisma: {
$queryRaw: jest.fn(),
tokenMetadata: { findMany: jest.fn() },
tokenTransfer: { findMany: jest.fn() },
},
}));

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

jest.mock("../../indexer", () => ({
getAllIndexerStats: jest.fn().mockReturnValue({}),
runningNetworks: jest.fn().mockReturnValue([]),
getIndexerStats: jest
.fn()
.mockReturnValue({ startedAt: "2024-01-01T00:00:00.000Z", uptimeSeconds: 0, totalIndexed: 0 }),
}));

import { createApp } from "../../api";
import { prisma } from "../../db";

const mockFindMany = prisma.tokenTransfer.findMany as jest.MockedFunction<
typeof prisma.tokenTransfer.findMany
>;

const CONTRACT = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM";
const ALICE = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF";

function row(id: number) {
return {
id,
contractId: CONTRACT,
eventType: "transfer",
fromAddress: ALICE,
toAddress: ALICE,
amount: "100",
ledger: 1000 + id,
ledgerClosedAt: new Date("2026-01-01T00:00:00Z"),
txHash: `hash${id}`,
eventId: `event${id}`,
isSac: false,
createdAt: new Date("2026-01-01T00:00:00Z"),
};
}

/**
* Serve `total` rows, honouring `skip`/`take` the way Prisma does.
*
* The cap probe is a `skip: max, take: 1` query against the same filter, so a
* mock that ignores `skip` would answer "truncated" for every request and the
* tests would pass for the wrong reason.
*/
function seed(total: number) {
mockFindMany.mockImplementation((async (args: {
skip?: number;
take?: number;
cursor?: { id: number };
}) => {
const all = Array.from({ length: total }, (_, i) => row(i + 1));
const after = args.cursor ? all.findIndex((r) => r.id === args.cursor!.id) + 1 : 0;
const from = after + (args.skip ?? 0);
return all.slice(from, from + (args.take ?? all.length));
}) as never);
}

beforeEach(() => {
jest.clearAllMocks();
});

/**
* Bounding and validating the exports (#185).
*
* The cap is the point of the feature, but the header that announces it is
* where the danger was: setting a response header after the CSV body has begun
* throws ERR_HTTP_HEADERS_SENT, which skips `csvStream.end()` and leaves the
* client holding a response that never terminates — and it only happens on the
* truncation path, the one case the signalling exists for.
*/
describe("GET /transfers.csv", () => {
it("rejects a non-numeric fromLedger with 400, not 500", async () => {
const res = await request(createApp()).get("/transfers.csv?fromLedger=abc");

expect(res.status).toBe(400);
expect(res.body.error).toBeTruthy();
// Nothing was queried: validation happens before the database is touched.
expect(mockFindMany).not.toHaveBeenCalled();
});

it("rejects an unparseable date with 400", async () => {
const res = await request(createApp()).get("/transfers.csv?fromDate=not-a-date");
expect(res.status).toBe(400);
});

it("rejects maxRows above the absolute ceiling", async () => {
const res = await request(createApp()).get("/transfers.csv?maxRows=999999999");
expect(res.status).toBe(400);
});

it("stops at the cap and says so, in headers that arrive before the body", async () => {
seed(10);

const res = await request(createApp()).get("/transfers.csv?maxRows=4");

expect(res.status).toBe(200);
expect(res.headers["x-truncated"]).toBe("true");
expect(res.headers["x-row-limit"]).toBe("4");

// The response terminated — the failure mode here was a body that never ends.
const dataLines = res.text.trim().split("\n").slice(1);
expect(dataLines).toHaveLength(4);
});

it("does not claim truncation when the rows end exactly at the cap", async () => {
seed(4);

const res = await request(createApp()).get("/transfers.csv?maxRows=4");

expect(res.status).toBe(200);
expect(res.headers["x-truncated"]).toBeUndefined();
expect(res.text.trim().split("\n").slice(1)).toHaveLength(4);
});

it("returns everything, unflagged, when the result is under the cap", async () => {
seed(3);

const res = await request(createApp()).get("/transfers.csv?maxRows=10");

expect(res.headers["x-truncated"]).toBeUndefined();
expect(res.text.trim().split("\n").slice(1)).toHaveLength(3);
});

it("returns an empty export rather than failing when nothing matches", async () => {
seed(0);

const res = await request(createApp()).get("/transfers.csv");

expect(res.status).toBe(200);
expect(res.headers["x-truncated"]).toBeUndefined();
});
});
Loading
Loading