diff --git a/package-lock.json b/package-lock.json index d13e2d39..96bf9410 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "@types/node": "^20.11.24", "@types/supertest": "^7.2.0", "@types/ws": "^8.18.1", + "fast-check": "^3.22.0", "jest": "^30.3.0", "prisma": "^5.10.0", "supertest": "^7.2.2", @@ -64,7 +65,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1776,7 +1776,6 @@ "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2578,7 +2577,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -3377,7 +3375,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -3437,6 +3434,46 @@ "express": ">= 4.11" } }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/fast-check/node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -3643,7 +3680,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -4311,7 +4347,6 @@ "integrity": "sha512-AkXIIFcaazymvey2i/+F94XRnM6TsVLZDhBMLsd1Sf/W0wzsvvpjeyUrCZD6HGG4SDYPgDJDBKeiJTBb10WzMg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.3.0", "@jest/types": "30.3.0", @@ -5800,7 +5835,6 @@ "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@prisma/engines": "5.22.0" }, @@ -6672,7 +6706,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -6886,7 +6919,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/src/__tests__/routes/transfers.test.ts b/src/__tests__/routes/transfers.test.ts index 9985e079..6323ab57 100644 --- a/src/__tests__/routes/transfers.test.ts +++ b/src/__tests__/routes/transfers.test.ts @@ -103,7 +103,6 @@ describe("Transfer route handlers", () => { const app = createApp(); beforeEach(() => { - // Default mocks for status/readyz side-effects mockGetLastIndexedLedger.mockResolvedValue(1020); mockGetLatestLedger.mockResolvedValue(1022); }); @@ -112,7 +111,7 @@ describe("Transfer route handlers", () => { describe("GET /transfers/incoming/:address", () => { it("returns all incoming transfers for a known address", async () => { const incoming = SEED_TRANSFERS.filter((t) => t.toAddress === ALICE); - mockQueryTransfers.mockResolvedValue({ total: incoming.length, transfers: incoming }); + mockQueryTransfers.mockResolvedValue({ total: incoming.length, transfers: incoming, nextCursor: null }); const res = await request(app).get(`/transfers/incoming/${ALICE}`); @@ -125,7 +124,7 @@ describe("Transfer route handlers", () => { it("attaches displayAmount to every transfer", async () => { const transfer = makeTransfer({ amount: "10000000" }); - mockQueryTransfers.mockResolvedValue({ total: 1, transfers: [transfer] }); + mockQueryTransfers.mockResolvedValue({ total: 1, transfers: [transfer], nextCursor: null }); const res = await request(app).get(`/transfers/incoming/${ALICE}`); @@ -134,7 +133,7 @@ describe("Transfer route handlers", () => { }); it("returns empty array for an unknown address", async () => { - mockQueryTransfers.mockResolvedValue({ total: 0, transfers: [] }); + mockQueryTransfers.mockResolvedValue({ total: 0, transfers: [], nextCursor: null }); const res = await request(app).get("/transfers/incoming/GUNKNOWNADDRESS"); @@ -147,7 +146,7 @@ describe("Transfer route handlers", () => { const filtered = SEED_TRANSFERS.filter( (t) => t.toAddress === ALICE && t.contractId === CONTRACT_A ); - mockQueryTransfers.mockResolvedValue({ total: filtered.length, transfers: filtered }); + mockQueryTransfers.mockResolvedValue({ total: filtered.length, transfers: filtered, nextCursor: null }); const res = await request(app) .get(`/transfers/incoming/${ALICE}`) @@ -187,7 +186,7 @@ describe("Transfer route handlers", () => { }); it("passes fromDate and toDate to queryTransfers", async () => { - mockQueryTransfers.mockResolvedValue({ total: 2, transfers: SEED_TRANSFERS.slice(14, 16) }); + mockQueryTransfers.mockResolvedValue({ total: 2, transfers: SEED_TRANSFERS.slice(14, 16), nextCursor: null }); const res = await request(app) .get(`/transfers/incoming/${ALICE}`) @@ -227,7 +226,7 @@ describe("Transfer route handlers", () => { }); it("accepts valid eventType values", async () => { - mockQueryTransfers.mockResolvedValue({ total: 1, transfers: [makeTransfer({ eventType: "mint" })] }); + mockQueryTransfers.mockResolvedValue({ total: 1, transfers: [makeTransfer({ eventType: "mint" })], nextCursor: null }); const res = await request(app) .get(`/transfers/incoming/${ALICE}`) @@ -240,7 +239,7 @@ describe("Transfer route handlers", () => { }); it("accepts comma-separated eventType values", async () => { - mockQueryTransfers.mockResolvedValue({ total: 2, transfers: [] }); + mockQueryTransfers.mockResolvedValue({ total: 2, transfers: [], nextCursor: null }); const res = await request(app) .get(`/transfers/incoming/${ALICE}`) @@ -254,7 +253,7 @@ describe("Transfer route handlers", () => { it("honours limit and offset for pagination", async () => { const page = SEED_TRANSFERS.slice(0, 5); - mockQueryTransfers.mockResolvedValue({ total: 20, transfers: page }); + mockQueryTransfers.mockResolvedValue({ total: 20, transfers: page, nextCursor: null }); const res = await request(app) .get(`/transfers/incoming/${ALICE}`) @@ -269,7 +268,7 @@ describe("Transfer route handlers", () => { }); it("falls back to limit=50, offset=0 when not provided", async () => { - mockQueryTransfers.mockResolvedValue({ total: 0, transfers: [] }); + mockQueryTransfers.mockResolvedValue({ total: 0, transfers: [], nextCursor: null }); await request(app).get(`/transfers/incoming/${ALICE}`); @@ -279,7 +278,7 @@ describe("Transfer route handlers", () => { }); it("forwards fromLedger and toLedger filters", async () => { - mockQueryTransfers.mockResolvedValue({ total: 3, transfers: SEED_TRANSFERS.slice(0, 3) }); + mockQueryTransfers.mockResolvedValue({ total: 3, transfers: SEED_TRANSFERS.slice(0, 3), nextCursor: null }); await request(app) .get(`/transfers/incoming/${ALICE}`) @@ -295,7 +294,7 @@ describe("Transfer route handlers", () => { describe("GET /transfers/outgoing/:address", () => { it("returns outgoing transfers with direction=outgoing", async () => { const outgoing = SEED_TRANSFERS.filter((t) => t.fromAddress === ALICE); - mockQueryTransfers.mockResolvedValue({ total: outgoing.length, transfers: outgoing }); + mockQueryTransfers.mockResolvedValue({ total: outgoing.length, transfers: outgoing, nextCursor: null }); const res = await request(app).get(`/transfers/outgoing/${ALICE}`); @@ -307,7 +306,7 @@ describe("Transfer route handlers", () => { }); it("returns empty array for address with no outgoing transfers", async () => { - mockQueryTransfers.mockResolvedValue({ total: 0, transfers: [] }); + mockQueryTransfers.mockResolvedValue({ total: 0, transfers: [], nextCursor: null }); const res = await request(app).get(`/transfers/outgoing/GNOBODY`); @@ -317,7 +316,7 @@ describe("Transfer route handlers", () => { it("attaches displayAmount for large i128 amounts", async () => { const t = makeTransfer({ amount: "1000000000000000" }); // 100000000.0000000 - mockQueryTransfers.mockResolvedValue({ total: 1, transfers: [t] }); + mockQueryTransfers.mockResolvedValue({ total: 1, transfers: [t], nextCursor: null }); const res = await request(app).get(`/transfers/outgoing/${ALICE}`); @@ -340,7 +339,7 @@ describe("Transfer route handlers", () => { (t) => t.toAddress === ALICE || t.fromAddress === ALICE ).map((t) => ({ ...t, direction: t.toAddress === ALICE ? "incoming" : "outgoing" })); - mockQueryAllTransfers.mockResolvedValue({ total: combined.length, transfers: combined }); + mockQueryAllTransfers.mockResolvedValue({ total: combined.length, transfers: combined, nextCursor: null }); const res = await request(app).get(`/transfers/address/${ALICE}`); @@ -352,7 +351,7 @@ describe("Transfer route handlers", () => { it("direction field is present on each record", async () => { const t1 = { ...makeTransfer({ id: 1, toAddress: ALICE, fromAddress: BOB }), direction: "incoming" }; const t2 = { ...makeTransfer({ id: 2, toAddress: BOB, fromAddress: ALICE }), direction: "outgoing" }; - mockQueryAllTransfers.mockResolvedValue({ total: 2, transfers: [t1, t2] }); + mockQueryAllTransfers.mockResolvedValue({ total: 2, transfers: [t1, t2], nextCursor: null }); const res = await request(app).get(`/transfers/address/${ALICE}`); @@ -361,7 +360,7 @@ describe("Transfer route handlers", () => { }); it("returns empty array for unknown address", async () => { - mockQueryAllTransfers.mockResolvedValue({ total: 0, transfers: [] }); + mockQueryAllTransfers.mockResolvedValue({ total: 0, transfers: [], nextCursor: null }); const res = await request(app).get("/transfers/address/GUNKNOWN"); @@ -382,7 +381,7 @@ describe("Transfer route handlers", () => { }); it("filters by contractId", async () => { - mockQueryAllTransfers.mockResolvedValue({ total: 3, transfers: [] }); + mockQueryAllTransfers.mockResolvedValue({ total: 3, transfers: [], nextCursor: null }); await request(app) .get(`/transfers/address/${ALICE}`) @@ -400,6 +399,65 @@ describe("Transfer route handlers", () => { expect(res.status).toBe(400); }); + + // ── token filter tests (issue #35) ──────────────────────────────────────── + + it("filters transfers by token contract address when ?token= is provided", async () => { + const tokenFiltered = SEED_TRANSFERS + .filter((t) => t.toAddress === ALICE || t.fromAddress === ALICE) + .filter((t) => t.contractId === CONTRACT_A) + .map((t) => ({ ...t, direction: t.toAddress === ALICE ? "incoming" : "outgoing" })); + + mockQueryAllTransfers.mockResolvedValue({ + total: tokenFiltered.length, + transfers: tokenFiltered, + nextCursor: null, + }); + + const res = await request(app) + .get(`/transfers/address/${ALICE}`) + .query({ token: CONTRACT_A }); + + expect(res.status).toBe(200); + expect(res.body.total).toBe(tokenFiltered.length); + expect(mockQueryAllTransfers).toHaveBeenCalledWith( + expect.objectContaining({ token: CONTRACT_A }) + ); + }); + + it("returns 400 when ?token= is not a valid Stellar contract address (wrong prefix)", async () => { + const res = await request(app) + .get(`/transfers/address/${ALICE}`) + .query({ token: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF" }); + + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/Invalid token address/i); + expect(res.body.error).toMatch(/56-character Stellar contract address starting with "C"/i); + }); + + it("returns 400 when ?token= is a C-address but the wrong length", async () => { + const res = await request(app) + .get(`/transfers/address/${ALICE}`) + .query({ token: "CSHORT" }); + + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/Invalid token address/i); + }); + + it("behaves identically to the unfiltered request when ?token= is absent", async () => { + const combined = SEED_TRANSFERS + .filter((t) => t.toAddress === ALICE || t.fromAddress === ALICE) + .map((t) => ({ ...t, direction: t.toAddress === ALICE ? "incoming" : "outgoing" })); + + mockQueryAllTransfers.mockResolvedValue({ total: combined.length, transfers: combined, nextCursor: null }); + + const res = await request(app).get(`/transfers/address/${ALICE}`); + + expect(res.status).toBe(200); + expect(mockQueryAllTransfers).toHaveBeenCalledWith( + expect.objectContaining({ token: undefined }) + ); + }); }); // ── /transfers/tx/:txHash ────────────────────────────────────────────────── @@ -483,19 +541,19 @@ describe("Transfer route handlers", () => { // ── toDisplayAmount edge cases ───────────────────────────────────────────── describe("toDisplayAmount formatting", () => { it("formats 0 correctly", async () => { - mockQueryTransfers.mockResolvedValue({ total: 1, transfers: [makeTransfer({ amount: "0" })] }); + mockQueryTransfers.mockResolvedValue({ total: 1, transfers: [makeTransfer({ amount: "0" })], nextCursor: null }); const res = await request(app).get(`/transfers/incoming/${ALICE}`); expect(res.body.transfers[0].displayAmount).toBe("0.0000000"); }); it("formats small amounts with leading zeros in fractional part", async () => { - mockQueryTransfers.mockResolvedValue({ total: 1, transfers: [makeTransfer({ amount: "1" })] }); + mockQueryTransfers.mockResolvedValue({ total: 1, transfers: [makeTransfer({ amount: "1" })], nextCursor: null }); const res = await request(app).get(`/transfers/incoming/${ALICE}`); expect(res.body.transfers[0].displayAmount).toBe("0.0000001"); }); it("formats exactly 1 token (10000000 stroops)", async () => { - mockQueryTransfers.mockResolvedValue({ total: 1, transfers: [makeTransfer({ amount: "10000000" })] }); + mockQueryTransfers.mockResolvedValue({ total: 1, transfers: [makeTransfer({ amount: "10000000" })], nextCursor: null }); const res = await request(app).get(`/transfers/incoming/${ALICE}`); expect(res.body.transfers[0].displayAmount).toBe("1.0000000"); }); @@ -543,7 +601,7 @@ describe("Transfer route handlers", () => { eventType: "transfer", }), direction: "incoming" as const }, ]; - mockQueryAllTransfers.mockResolvedValue({ total: 1, transfers }); + mockQueryAllTransfers.mockResolvedValue({ total: 1, transfers, nextCursor: null }); const res = await request(app).get(`/transfers/address/${ALICE}/export.csv`); @@ -575,14 +633,14 @@ describe("Transfer route handlers", () => { eventType: "mint", }), direction: "incoming" as const }, ]; - mockQueryAllTransfers.mockResolvedValue({ total: 2, transfers }); + mockQueryAllTransfers.mockResolvedValue({ total: 2, transfers, nextCursor: null }); const res = await request(app).get(`/transfers/address/${ALICE}/export.csv`); expect(res.status).toBe(200); const lines = res.text.split("\n"); - expect(lines).toHaveLength(3); // header + 2 rows - expect(lines[1]).toContain("1.0000000"); // displayAmount for 10000000 stroops + expect(lines).toHaveLength(3); + expect(lines[1]).toContain("1.0000000"); expect(lines[1]).toContain(BOB); expect(lines[1]).toContain(CONTRACT_A); expect(lines[1]).toContain("1001"); @@ -595,13 +653,13 @@ describe("Transfer route handlers", () => { fromAddress: ALICE, toAddress: BOB, contractId: CONTRACT_A, - amount: "100000000", // 10.0000000 + amount: "100000000", ledgerClosedAt: new Date("2025-01-15T10:30:45Z"), ledger: 1001, eventType: "transfer", }), direction: "outgoing" as const }, ]; - mockQueryAllTransfers.mockResolvedValue({ total: 1, transfers }); + mockQueryAllTransfers.mockResolvedValue({ total: 1, transfers, nextCursor: null }); const res = await request(app).get(`/transfers/address/${ALICE}/export.csv`); @@ -622,13 +680,12 @@ describe("Transfer route handlers", () => { eventType: "mint", }), direction: "incoming" as const }, ]; - mockQueryAllTransfers.mockResolvedValue({ total: 1, transfers }); + mockQueryAllTransfers.mockResolvedValue({ total: 1, transfers, nextCursor: null }); const res = await request(app).get(`/transfers/address/${ALICE}/export.csv`); expect(res.status).toBe(200); - // After "mint" there should be an empty field (,,) for the empty fromAddress - expect(res.text).toContain("mint,,"); // type,empty-from,to + expect(res.text).toContain("mint,,"); }); it("handles null toAddress by using empty string in CSV", async () => { @@ -644,17 +701,17 @@ describe("Transfer route handlers", () => { eventType: "burn", }), direction: "outgoing" as const }, ]; - mockQueryAllTransfers.mockResolvedValue({ total: 1, transfers }); + mockQueryAllTransfers.mockResolvedValue({ total: 1, transfers, nextCursor: null }); const res = await request(app).get(`/transfers/address/${ALICE}/export.csv`); expect(res.status).toBe(200); - expect(res.text).toContain(ALICE); // fromAddress - expect(res.text).toContain(",burn,"); // contains burn event + expect(res.text).toContain(ALICE); + expect(res.text).toContain(",burn,"); }); it("sets Content-Disposition header with filename", async () => { - mockQueryAllTransfers.mockResolvedValue({ total: 0, transfers: [] }); + mockQueryAllTransfers.mockResolvedValue({ total: 0, transfers: [], nextCursor: null }); const res = await request(app).get(`/transfers/address/${ALICE}/export.csv`); @@ -665,7 +722,7 @@ describe("Transfer route handlers", () => { }); it("respects contractId filter", async () => { - mockQueryAllTransfers.mockResolvedValue({ total: 0, transfers: [] }); + mockQueryAllTransfers.mockResolvedValue({ total: 0, transfers: [], nextCursor: null }); await request(app) .get(`/transfers/address/${ALICE}/export.csv`) @@ -677,7 +734,7 @@ describe("Transfer route handlers", () => { }); it("respects fromDate and toDate filters", async () => { - mockQueryAllTransfers.mockResolvedValue({ total: 0, transfers: [] }); + mockQueryAllTransfers.mockResolvedValue({ total: 0, transfers: [], nextCursor: null }); await request(app) .get(`/transfers/address/${ALICE}/export.csv`) @@ -695,7 +752,7 @@ describe("Transfer route handlers", () => { }); it("respects eventType filter", async () => { - mockQueryAllTransfers.mockResolvedValue({ total: 0, transfers: [] }); + mockQueryAllTransfers.mockResolvedValue({ total: 0, transfers: [], nextCursor: null }); await request(app) .get(`/transfers/address/${ALICE}/export.csv`) @@ -707,7 +764,7 @@ describe("Transfer route handlers", () => { }); it("enforces a 10,000 row cap for export", async () => { - mockQueryAllTransfers.mockResolvedValue({ total: 50000, transfers: [] }); + mockQueryAllTransfers.mockResolvedValue({ total: 50000, transfers: [], nextCursor: null }); await request(app).get(`/transfers/address/${ALICE}/export.csv`); @@ -717,7 +774,7 @@ describe("Transfer route handlers", () => { }); it("always uses offset=0 for CSV export", async () => { - mockQueryAllTransfers.mockResolvedValue({ total: 100, transfers: [] }); + mockQueryAllTransfers.mockResolvedValue({ total: 100, transfers: [], nextCursor: null }); await request(app).get(`/transfers/address/${ALICE}/export.csv`); @@ -745,7 +802,7 @@ describe("Transfer route handlers", () => { }); it("returns empty CSV (header only) for address with no transfers", async () => { - mockQueryAllTransfers.mockResolvedValue({ total: 0, transfers: [] }); + mockQueryAllTransfers.mockResolvedValue({ total: 0, transfers: [], nextCursor: null }); const res = await request(app).get(`/transfers/address/${ALICE}/export.csv`); @@ -766,7 +823,7 @@ describe("Transfer route handlers", () => { eventType: "transfer", }), direction: "incoming" as const }, ]; - mockQueryAllTransfers.mockResolvedValue({ total: 1, transfers }); + mockQueryAllTransfers.mockResolvedValue({ total: 1, transfers, nextCursor: null }); const res = await request(app).get(`/transfers/address/${ALICE}/export.csv`); @@ -774,5 +831,4 @@ describe("Transfer route handlers", () => { expect(res.text).toContain('"CONTRACT,WITH,COMMAS"'); }); }); -}); - +}); \ No newline at end of file diff --git a/src/api.ts b/src/api.ts index 24c38372..d2a96a6a 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,7 +1,6 @@ import express, { Request, Response, NextFunction } from "express"; import cors from "cors"; import rateLimit from "express-rate-limit"; -import { queryTransfers, queryAllTransfers, queryByTxHash, querySummary, getLastIndexedLedger, prisma } from "./db"; import { queryHostFnLogs } from "./indexer/host-fn-log"; import { queryTransfers, queryAllTransfers, queryByTxHash, querySummary, queryNftTransfers, getNftOwner, getNftMetadata, getLastIndexedLedger, prisma } from "./db"; import { getLatestLedger } from "./rpc"; @@ -348,7 +347,20 @@ export function createApp(): express.Application { async (req: Request, res: Response, next: NextFunction) => { try { const { address } = req.params; - const { contractId, fromLedger, toLedger, fromDate, toDate, eventType, limit, offset, cursor, $filter, $select } = req.query; + const { + contractId, + fromLedger, + toLedger, + fromDate, + toDate, + eventType, + limit, + offset, + token, + cursor, + $filter, + $select, + } = req.query; const fromDateVal = parseDateParam(fromDate, res); if (fromDateVal === null) return; @@ -357,12 +369,25 @@ export function createApp(): express.Application { const eventTypes = parseEventTypes(eventType, res); if (eventTypes === null) return; + // Validate optional ?token= query param. + // Must be a 56-character Stellar SAC contract address starting with "C". + if (token !== undefined) { + const tokenStr = String(token).trim(); + if (!tokenStr.startsWith("C") || tokenStr.length !== 56) { + res.status(400).json({ + error: `Invalid token address: "${tokenStr}". Must be a 56-character Stellar contract address starting with "C".`, + }); + return; + } + } + const lim = parseIntParam(limit, 50); const off = parseIntParam(offset, 0); const result = await queryAllTransfers({ address, contractId: contractId as string | undefined, + token: token !== undefined ? String(token).trim() : undefined, filter: $filter as string | undefined, select: parseSelectQuery($select), cursor: cursor as string | undefined, @@ -589,6 +614,13 @@ export function createApp(): express.Application { limit: Math.min(limit, 200), offset, logs, + }); + } catch (err) { + next(err); + } + } + ); + // ── GET /nfts/transfers ────────────────────────────────────────────────────── /** * Query CAP-46 NFT transfer events. @@ -674,7 +706,6 @@ export function createApp(): express.Application { } catch (err) { next(err); } - }, } ); diff --git a/src/db.ts b/src/db.ts index 2a9bc125..42b1d78a 100644 --- a/src/db.ts +++ b/src/db.ts @@ -651,6 +651,7 @@ export async function queryAccountSummaries(params: AccountSummaryQueryParams) { export type AllTransfersQueryParams = { address: string; contractId?: string; + token?: string; filter?: string; select?: string[]; cursor?: string; @@ -667,6 +668,7 @@ export async function queryAllTransfers(params: AllTransfersQueryParams) { const { address, contractId, + token, filter, select, cursor, @@ -682,6 +684,7 @@ export async function queryAllTransfers(params: AllTransfersQueryParams) { const baseWhere: Prisma.TokenTransferWhereInput = { OR: [{ toAddress: address }, { fromAddress: address }], ...(contractId ? { contractId } : {}), + ...(token ? { contractId: token } : {}), ...(eventTypes?.length ? { eventType: { in: eventTypes } } : {}), ...(fromLedger || toLedger ? { diff --git a/src/indexer.ts b/src/indexer.ts index 39f7f9e9..142bc2b4 100644 --- a/src/indexer.ts +++ b/src/indexer.ts @@ -74,10 +74,7 @@ const POLL_INTERVAL_MS = parseInt(process.env.POLL_INTERVAL_MS ?? "6000", 1 const BATCH_SIZE = parseInt(process.env.EVENTS_BATCH_SIZE ?? "10000", 10); const INGEST_WORKERS = parseInt(process.env.INGEST_WORKERS ?? "1", 10); const SAC_CONTRACT_IDS = resolveSacContractIds(); -const POLL_INTERVAL_MS = parseInt(process.env.POLL_INTERVAL_MS ?? "6000", 10); -const BATCH_SIZE = parseInt(process.env.EVENTS_BATCH_SIZE ?? "10000", 10); -const SAC_CONTRACT_IDS = resolveSacContractIds(); -const NFT_CONTRACT_IDS = resolveNftContractIds(); +const NFT_CONTRACT_IDS = resolveNftContractIds(); // Combined watch list — deduplicated so we don't request the same contract twice const ALL_CONTRACT_IDS = [...new Set([...SAC_CONTRACT_IDS, ...NFT_CONTRACT_IDS])]; const sourceSwitcher = createSourceSwitcherWithConfig({ @@ -135,9 +132,6 @@ async function pollOnce( return highestLedger; } - // Parse token transfer events - const records = parseEvents(events); - // Persist token transfers // Split events by type: NFT (4 topics) vs fungible (3 topics) const fungibleEvents = events.filter((e) => !isNftTransferEvent(e)); @@ -168,6 +162,8 @@ async function pollOnce( await upsertHostFnLogs(hostFnRecords).catch(err => console.error("[indexer] host-fn log error:", err), ); + } + // ── NFT path ───────────────────────────────────────────────────────────────── const nftParsed = parseNftEvents(nftRawEvents); const nftRecords = nftParsed.map((p) => p.record);