diff --git a/README.md b/README.md index a336255b..2c2b4e52 100644 --- a/README.md +++ b/README.md @@ -331,6 +331,55 @@ console.log(data); Base URL: `http://localhost:3000` +### Selecting a network + +Wraith stores testnet and mainnet rows in the same tables, discriminated by a +`network` column, and a single process can index both (`NETWORKS=testnet,mainnet`). +Every read route accepts a selector so a caller can say which one it wants: + +```bash +# query parameter +curl "http://localhost:3000/transfers/incoming/GABC…?network=mainnet" + +# or a header — the query parameter wins if both are present +curl -H "X-Network: mainnet" http://localhost:3000/transfers/incoming/GABC… +``` + +Omit it and you get the deployment's configured network (`STELLAR_NETWORK`, +defaulting to testnet) — the behaviour every route had before the selector +existed, so nothing changes for existing callers. + +Two kinds of rejection, both `400`, because they need different fixes: + +| Request | Response | +| ------- | -------- | +| `?network=mainet` | `Invalid network: "mainet". Valid values: testnet, mainnet.` | +| `?network=mainnet` on a testnet-only deployment | `Network "mainnet" is not enabled on this deployment. Enabled networks: testnet.` | + +An un-indexed network is refused rather than answered with an empty list: "no +transfers" and "this process has never looked at that chain" are different +statements, and returning `[]` for both is how a dashboard ends up confidently +showing zero. + +**GraphQL** takes the same `?network=` / `X-Network` selector, and each field +also accepts a `network:` argument that overrides it — so one document can read +both chains in a single round-trip: + +```graphql +{ + testnet: transfers(address: "GABC…", network: TESTNET) { total } + mainnet: transfers(address: "GABC…", network: MAINNET) { total } +} +``` + +**WebSockets** take it on the upgrade URL — `ws://host/subscribe/GABC…?network=mainnet` +— and the stream is filtered to that network. A socket opened with an invalid or +un-enabled selector is closed with code `1008` and the reason, rather than left +open delivering nothing. GraphQL subscriptions work the same way on +`/graphql/subscriptions`, with an optional per-subscription `network:` argument. + +*** + ### `GET /status` Indexer health — current ledger, network tip, lag, uptime. @@ -342,19 +391,27 @@ curl http://localhost:3000/status ```json { "ok": true, + "network": "testnet", "lastIndexedLedger": 5842100, "last_indexed_ledger": 5842100, "latestLedger": 5842102, "lagLedgers": 2, "startedAt": "2025-10-01T10:00:00.000Z", "uptimeSeconds": 3600, - "totalIndexed": 12430 + "totalIndexed": 12430, + "networks": { + "testnet": { "lastIndexedLedger": 5842100, "latestLedger": 5842102, "lagLedgers": 2, "running": true } + } } ``` `last_indexed_ledger` is a snake_case alias of `lastIndexedLedger` — the same name the Prometheus gauge is exported under. Both always carry the same value. +`network` names which chain the top-level fields describe — it follows the +selector. `networks` reports every running loop regardless of the selector, so a +single response shows one chain falling behind while the other is healthy. + *** ### `GET /metrics` @@ -391,12 +448,46 @@ API rate limit so scrapes do not go dark under load. *** +### `GET /readyz` + +Readiness probe. `checks` and `as_of_ledger` describe the selected network; +`networks` carries the same checks for every enabled network. + +```json +{ + "ok": true, + "status": "healthy", + "network": "testnet", + "checks": { "db": true, "rpc": true, "indexerCaughtUp": true }, + "networks": { + "testnet": { + "checks": { "db": true, "rpc": true, "indexerCaughtUp": true }, + "lastIndexedLedger": 5842100, + "latestLedger": 5842102, + "lagLedgers": 2 + }, + "mainnet": { + "checks": { "db": true, "rpc": false, "indexerCaughtUp": false }, + "lastIndexedLedger": 51234000, + "latestLedger": null, + "lagLedgers": null + } + } +} +``` + +The database is checked once rather than per network — a dead database is not a +per-chain condition — and a `503 down` verdict still reports every network. + +*** + ### `GET /transfers/incoming/:address` All token transfers **received** by an address. | Param | Type | Description | | ------------ | ------ | -------------------------------------------- | +| `network` | string | `testnet` or `mainnet` (see above) | | `contractId` | string | Filter to a specific token contract (`C...`) | | `fromLedger` | int | Inclusive lower ledger bound | | `toLedger` | int | Inclusive upper ledger bound | @@ -449,6 +540,7 @@ curl "http://localhost:3000/transfers/tx/abcdef1234567890..." | `CONTRACT_IDS` | *(all)* | Comma-separated token contract IDs to watch. Empty = watch all (very heavy on mainnet) | | `EVENTS_BATCH_SIZE` | `10000` | Max events per RPC call (Stellar RPC hard-cap is 10 000) | | `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 | ### RPC URL Resolution diff --git a/openapi.json b/openapi.json index fd39d382..4682ea68 100644 --- a/openapi.json +++ b/openapi.json @@ -110,6 +110,23 @@ "get": { "summary": "Readiness probe", "parameters": [ + { + "schema": { + "type": "string", + "nullable": true, + "enum": [ + "testnet", + "mainnet", + null + ], + "description": "Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins.", + "example": "mainnet" + }, + "required": false, + "description": "Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins.", + "name": "network", + "in": "query" + }, { "schema": { "type": "integer", @@ -134,6 +151,13 @@ "ok": { "type": "boolean" }, + "network": { + "type": "string", + "enum": [ + "testnet", + "mainnet" + ] + }, "checks": { "type": "object", "properties": { @@ -152,6 +176,51 @@ "rpc", "indexerCaughtUp" ] + }, + "networks": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "checks": { + "type": "object", + "properties": { + "db": { + "type": "boolean" + }, + "rpc": { + "type": "boolean" + }, + "indexerCaughtUp": { + "type": "boolean" + } + }, + "required": [ + "db", + "rpc", + "indexerCaughtUp" + ] + }, + "lastIndexedLedger": { + "type": "integer", + "nullable": true + }, + "latestLedger": { + "type": "integer", + "nullable": true + }, + "lagLedgers": { + "type": "integer", + "nullable": true + } + }, + "required": [ + "checks", + "lastIndexedLedger", + "latestLedger", + "lagLedgers" + ] + } } }, "required": [ @@ -226,6 +295,13 @@ "ok": { "type": "boolean" }, + "network": { + "type": "string", + "enum": [ + "testnet", + "mainnet" + ] + }, "checks": { "type": "object", "properties": { @@ -244,6 +320,51 @@ "rpc", "indexerCaughtUp" ] + }, + "networks": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "checks": { + "type": "object", + "properties": { + "db": { + "type": "boolean" + }, + "rpc": { + "type": "boolean" + }, + "indexerCaughtUp": { + "type": "boolean" + } + }, + "required": [ + "db", + "rpc", + "indexerCaughtUp" + ] + }, + "lastIndexedLedger": { + "type": "integer", + "nullable": true + }, + "latestLedger": { + "type": "integer", + "nullable": true + }, + "lagLedgers": { + "type": "integer", + "nullable": true + } + }, + "required": [ + "checks", + "lastIndexedLedger", + "latestLedger", + "lagLedgers" + ] + } } }, "required": [ @@ -278,6 +399,25 @@ "/status": { "get": { "summary": "Indexer status", + "parameters": [ + { + "schema": { + "type": "string", + "nullable": true, + "enum": [ + "testnet", + "mainnet", + null + ], + "description": "Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins.", + "example": "mainnet" + }, + "required": false, + "description": "Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins.", + "name": "network", + "in": "query" + } + ], "responses": { "200": { "description": "OK", @@ -292,6 +432,13 @@ true ] }, + "network": { + "type": "string", + "enum": [ + "testnet", + "mainnet" + ] + }, "lastIndexedLedger": { "type": "integer", "nullable": true @@ -397,6 +544,23 @@ "name": "address", "in": "path" }, + { + "schema": { + "type": "string", + "nullable": true, + "enum": [ + "testnet", + "mainnet", + null + ], + "description": "Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins.", + "example": "mainnet" + }, + "required": false, + "description": "Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins.", + "name": "network", + "in": "query" + }, { "schema": { "type": "string", @@ -714,6 +878,23 @@ "name": "address", "in": "path" }, + { + "schema": { + "type": "string", + "nullable": true, + "enum": [ + "testnet", + "mainnet", + null + ], + "description": "Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins.", + "example": "mainnet" + }, + "required": false, + "description": "Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins.", + "name": "network", + "in": "query" + }, { "schema": { "type": "string", @@ -1031,6 +1212,23 @@ "name": "address", "in": "path" }, + { + "schema": { + "type": "string", + "nullable": true, + "enum": [ + "testnet", + "mainnet", + null + ], + "description": "Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins.", + "example": "mainnet" + }, + "required": false, + "description": "Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins.", + "name": "network", + "in": "query" + }, { "schema": { "type": "string", @@ -1348,6 +1546,23 @@ "name": "address", "in": "path" }, + { + "schema": { + "type": "string", + "nullable": true, + "enum": [ + "testnet", + "mainnet", + null + ], + "description": "Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins.", + "example": "mainnet" + }, + "required": false, + "description": "Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins.", + "name": "network", + "in": "query" + }, { "schema": { "type": "string", @@ -1673,6 +1888,23 @@ "name": "address", "in": "path" }, + { + "schema": { + "type": "string", + "nullable": true, + "enum": [ + "testnet", + "mainnet", + null + ], + "description": "Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins.", + "example": "mainnet" + }, + "required": false, + "description": "Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins.", + "name": "network", + "in": "query" + }, { "schema": { "type": "string", @@ -1868,6 +2100,23 @@ "name": "address", "in": "path" }, + { + "schema": { + "type": "string", + "nullable": true, + "enum": [ + "testnet", + "mainnet", + null + ], + "description": "Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins.", + "example": "mainnet" + }, + "required": false, + "description": "Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins.", + "name": "network", + "in": "query" + }, { "schema": { "type": "string", @@ -2063,6 +2312,23 @@ "name": "address", "in": "path" }, + { + "schema": { + "type": "string", + "nullable": true, + "enum": [ + "testnet", + "mainnet", + null + ], + "description": "Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins.", + "example": "mainnet" + }, + "required": false, + "description": "Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins.", + "name": "network", + "in": "query" + }, { "schema": { "type": "string", @@ -2983,6 +3249,23 @@ "name": "contractId", "in": "path" }, + { + "schema": { + "type": "string", + "nullable": true, + "enum": [ + "testnet", + "mainnet", + null + ], + "description": "Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins.", + "example": "mainnet" + }, + "required": false, + "description": "Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins.", + "name": "network", + "in": "query" + }, { "schema": { "type": "string", @@ -3162,6 +3445,23 @@ "get": { "summary": "NFT transfers", "parameters": [ + { + "schema": { + "type": "string", + "nullable": true, + "enum": [ + "testnet", + "mainnet", + null + ], + "description": "Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins.", + "example": "mainnet" + }, + "required": false, + "description": "Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins.", + "name": "network", + "in": "query" + }, { "schema": { "type": "string", @@ -3558,6 +3858,23 @@ "get": { "summary": "Popular assets", "parameters": [ + { + "schema": { + "type": "string", + "nullable": true, + "enum": [ + "testnet", + "mainnet", + null + ], + "description": "Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins.", + "example": "mainnet" + }, + "required": false, + "description": "Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins.", + "name": "network", + "in": "query" + }, { "schema": { "type": "string", @@ -3744,6 +4061,23 @@ "get": { "summary": "Fuzzy search across accounts, assets, and contracts", "parameters": [ + { + "schema": { + "type": "string", + "nullable": true, + "enum": [ + "testnet", + "mainnet", + null + ], + "description": "Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins.", + "example": "mainnet" + }, + "required": false, + "description": "Network to read from. Defaults to the deployment's configured network. May also be sent as the X-Network header; the query parameter wins.", + "name": "network", + "in": "query" + }, { "schema": { "type": "string", diff --git a/package-lock.json b/package-lock.json index 0ace7a77..de0825ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5529,6 +5529,7 @@ "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, diff --git a/src/__tests__/accountSummary.test.ts b/src/__tests__/accountSummary.test.ts index fc4ab83d..0764bb10 100644 --- a/src/__tests__/accountSummary.test.ts +++ b/src/__tests__/accountSummary.test.ts @@ -84,7 +84,7 @@ describe("GET /accounts/:address/summary", () => { await supertest(app).get(`/accounts/${ALICE}/summary?contractId=${CONTRACT}`); - expect(getAccountSummary).toHaveBeenCalledWith(ALICE, CONTRACT); + expect(getAccountSummary).toHaveBeenCalledWith(ALICE, CONTRACT, "testnet"); }); it("returns account transfers and supports token filter", async () => { diff --git a/src/__tests__/networkSelector.test.ts b/src/__tests__/networkSelector.test.ts new file mode 100644 index 00000000..1f12bbdc --- /dev/null +++ b/src/__tests__/networkSelector.test.ts @@ -0,0 +1,265 @@ +import request from "supertest"; +import { createApp, clearRpcHealthCache } from "../api"; + +jest.mock("../db", () => ({ + queryTransfers: jest.fn(), + queryAllTransfers: jest.fn(), + queryByTxHash: jest.fn(), + querySummary: jest.fn(), + queryNftTransfers: jest.fn(), + getNftOwner: jest.fn(), + getNftMetadata: jest.fn(), + getLastIndexedLedger: jest.fn(), + getAccountSummary: jest.fn(), + queryPopularAssets: jest.fn(), + toDisplayAmount: jest.requireActual("../db").toDisplayAmount, + prisma: { $queryRaw: 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 { + queryTransfers, + queryAllTransfers, + queryByTxHash, + querySummary, + getLastIndexedLedger, + prisma, +} from "../db"; +import { getLatestLedger } from "../rpc"; + +const mockQueryTransfers = queryTransfers as jest.MockedFunction; +const mockQueryAllTransfers = queryAllTransfers as jest.MockedFunction; +const mockQueryByTxHash = queryByTxHash as jest.MockedFunction; +const mockQuerySummary = querySummary as jest.MockedFunction; +const mockGetLastIndexedLedger = getLastIndexedLedger as jest.MockedFunction; +const mockGetLatestLedger = getLatestLedger as jest.MockedFunction; +const mockQueryRaw = prisma.$queryRaw as jest.MockedFunction; + +const ALICE = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; + +const emptyPage = { total: 0, transfers: [], nextCursor: null }; + +describe("API network selector (#163)", () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + jest.clearAllMocks(); + clearRpcHealthCache(); + // Both chains indexed, so a selector has something to choose between. + process.env.NETWORKS = "testnet,mainnet"; + process.env.STELLAR_NETWORK = "testnet"; + mockQueryTransfers.mockResolvedValue(emptyPage as never); + mockQueryAllTransfers.mockResolvedValue(emptyPage as never); + mockQueryByTxHash.mockResolvedValue([] as never); + mockQuerySummary.mockResolvedValue([] as never); + mockGetLastIndexedLedger.mockResolvedValue(1000); + mockGetLatestLedger.mockResolvedValue(1050); + mockQueryRaw.mockResolvedValue([{ 1: 1 }] as never); + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + describe("selection", () => { + it("defaults to the configured network when no selector is given", async () => { + await request(createApp()).get(`/transfers/incoming/${ALICE}`).expect(200); + + expect(mockQueryTransfers).toHaveBeenCalledWith( + expect.objectContaining({ network: "testnet" }), + ); + }); + + it("reads mainnet when ?network=mainnet is given", async () => { + await request(createApp()).get(`/transfers/incoming/${ALICE}?network=mainnet`).expect(200); + + expect(mockQueryTransfers).toHaveBeenCalledWith( + expect.objectContaining({ network: "mainnet" }), + ); + }); + + it("accepts the X-Network header as an alternative to the query param", async () => { + await request(createApp()) + .get(`/transfers/incoming/${ALICE}`) + .set("X-Network", "mainnet") + .expect(200); + + expect(mockQueryTransfers).toHaveBeenCalledWith( + expect.objectContaining({ network: "mainnet" }), + ); + }); + + it("lets the query param win over the header", async () => { + // A pasted link should beat whatever default an HTTP client sets. + await request(createApp()) + .get(`/transfers/incoming/${ALICE}?network=mainnet`) + .set("X-Network", "testnet") + .expect(200); + + expect(mockQueryTransfers).toHaveBeenCalledWith( + expect.objectContaining({ network: "mainnet" }), + ); + }); + + it("is case- and whitespace-insensitive", async () => { + await request(createApp()).get(`/transfers/incoming/${ALICE}?network=%20MAINNET%20`).expect(200); + + expect(mockQueryTransfers).toHaveBeenCalledWith( + expect.objectContaining({ network: "mainnet" }), + ); + }); + + it("threads the selection through every read route", async () => { + const app = createApp(); + + await request(app).get(`/transfers/outgoing/${ALICE}?network=mainnet`).expect(200); + await request(app).get(`/transfers/address/${ALICE}?network=mainnet`).expect(200); + await request(app).get("/transfers/tx/deadbeef?network=mainnet").expect(200); + await request(app).get(`/summary/${ALICE}?network=mainnet`).expect(200); + + expect(mockQueryTransfers).toHaveBeenCalledWith( + expect.objectContaining({ network: "mainnet", direction: "outgoing" }), + ); + expect(mockQueryAllTransfers).toHaveBeenCalledWith( + expect.objectContaining({ network: "mainnet" }), + ); + expect(mockQueryByTxHash).toHaveBeenCalledWith("deadbeef", "mainnet"); + expect(mockQuerySummary).toHaveBeenCalledWith( + expect.objectContaining({ network: "mainnet" }), + ); + }); + }); + + describe("validation", () => { + it("400s on a value that is not a network at all", async () => { + const res = await request(createApp()) + .get(`/transfers/incoming/${ALICE}?network=mainet`) + .expect(400); + + expect(res.body.error).toContain('Invalid network: "mainet"'); + expect(res.body.error).toContain("testnet, mainnet"); + expect(mockQueryTransfers).not.toHaveBeenCalled(); + }); + + it("400s on a real network this deployment does not serve", async () => { + // A different fix from a typo: the caller needs a deployment that indexes + // it, so the message names what this one actually has. + process.env.NETWORKS = "testnet"; + + const res = await request(createApp()) + .get(`/transfers/incoming/${ALICE}?network=mainnet`) + .expect(400); + + expect(res.body.error).toContain('Network "mainnet" is not enabled'); + expect(res.body.error).toContain("Enabled networks: testnet"); + expect(mockQueryTransfers).not.toHaveBeenCalled(); + }); + + it("rejects before touching the database, never with empty results", async () => { + // Returning [] would read as "no such transfers" rather than "this + // process has never indexed that chain". + const res = await request(createApp()) + .get(`/transfers/incoming/${ALICE}?network=solana`) + .expect(400); + + expect(res.body).not.toHaveProperty("transfers"); + expect(mockQueryTransfers).not.toHaveBeenCalled(); + }); + + it("rejects an invalid selector on the health routes too", async () => { + await request(createApp()).get("/status?network=nope").expect(400); + await request(createApp()).get("/readyz?network=nope").expect(400); + }); + }); + + describe("/status", () => { + it("reports the selected network and scopes its ledger fields to it", async () => { + const res = await request(createApp()).get("/status?network=mainnet").expect(200); + + expect(res.body.network).toBe("mainnet"); + expect(mockGetLastIndexedLedger).toHaveBeenCalledWith("mainnet"); + expect(mockGetLatestLedger).toHaveBeenCalledWith("mainnet"); + }); + }); + + describe("/readyz", () => { + it("reports per-network health for every enabled network", async () => { + const res = await request(createApp()).get("/readyz").expect(200); + + expect(res.body.network).toBe("testnet"); + expect(Object.keys(res.body.networks).sort()).toEqual(["mainnet", "testnet"]); + expect(res.body.networks.testnet.checks).toEqual({ + db: true, + rpc: true, + indexerCaughtUp: true, + }); + expect(res.body.networks.mainnet.checks.rpc).toBe(true); + }); + + it("shows one network degraded while the other stays healthy", async () => { + // The whole point of per-network health: a single merged verdict cannot + // say which chain is behind. + mockGetLatestLedger.mockImplementation(async (net?: string) => { + if (net === "mainnet") throw new Error("mainnet RPC down"); + return 1050; + }); + + const res = await request(createApp()).get("/readyz").expect(200); + + expect(res.body.networks.testnet.checks.rpc).toBe(true); + expect(res.body.networks.mainnet.checks.rpc).toBe(false); + // Top-level still describes the selected network, which is healthy. + expect(res.body.status).toBe("healthy"); + }); + + it("degrades the top-level verdict when the selected network is the broken one", async () => { + mockGetLatestLedger.mockImplementation(async (net?: string) => { + if (net === "mainnet") throw new Error("mainnet RPC down"); + return 1050; + }); + + const res = await request(createApp()).get("/readyz?network=mainnet").expect(200); + + expect(res.body.network).toBe("mainnet"); + expect(res.body.status).toBe("degraded"); + expect(res.body.stale).toBe(true); + }); + + it("still 503s for every network when the database is down", async () => { + mockQueryRaw.mockRejectedValue(new Error("db down")); + + const res = await request(createApp()).get("/readyz").expect(503); + + expect(res.body.status).toBe("down"); + expect(res.body.networks.testnet.checks.db).toBe(false); + expect(res.body.networks.mainnet.checks.db).toBe(false); + }); + }); + + describe("stale-read staleness is per network", () => { + it("does not mark testnet reads stale because mainnet RPC is down", async () => { + mockGetLatestLedger.mockImplementation(async (net?: string) => { + if (net === "mainnet") throw new Error("mainnet RPC down"); + return 1050; + }); + + const app = createApp(); + const mainnet = await request(app).get(`/transfers/incoming/${ALICE}?network=mainnet`); + const testnet = await request(app).get(`/transfers/incoming/${ALICE}?network=testnet`); + + expect(mainnet.headers["x-data-stale"]).toBe("true"); + expect(testnet.headers["x-data-stale"]).toBeUndefined(); + }); + }); +}); diff --git a/src/__tests__/networkSelectorGraphqlWs.test.ts b/src/__tests__/networkSelectorGraphqlWs.test.ts new file mode 100644 index 00000000..81593ee1 --- /dev/null +++ b/src/__tests__/networkSelectorGraphqlWs.test.ts @@ -0,0 +1,225 @@ +import { createServer, type Server } from "http"; +import request from "supertest"; +import WebSocket from "ws"; +import { createApp } from "../api"; +import { attachWebSocketServer, resolveSocketNetwork } from "../ws"; +import { emitTransfer } from "../events"; +import type { TransferRecord } from "../db"; + +jest.mock("../db", () => ({ + getAccountSummary: jest.fn().mockResolvedValue([]), + getLastIndexedLedger: jest.fn().mockResolvedValue(1), + getNftMetadata: jest.fn(), + getNftOwner: jest.fn(), + prisma: { $queryRaw: jest.fn() }, + queryAllTransfers: jest.fn().mockResolvedValue({ total: 0, transfers: [], nextCursor: null }), + queryByTxHash: jest.fn().mockResolvedValue([]), + queryNftTransfers: jest.fn().mockResolvedValue({ total: 0, transfers: [], nextCursor: null }), + querySummary: jest.fn().mockResolvedValue([]), + queryTransfers: jest.fn().mockResolvedValue({ total: 0, transfers: [], nextCursor: null }), + toDisplayAmount: jest.requireActual("../db").toDisplayAmount, +})); + +jest.mock("../rpc", () => ({ getLatestLedger: jest.fn().mockResolvedValue(1) })); + +jest.mock("../indexer", () => ({ + getAllIndexerStats: jest.fn().mockReturnValue({}), + runningNetworks: jest.fn().mockReturnValue([]), + getIndexerStats: jest.fn().mockReturnValue({ uptimeSeconds: 0, totalIndexed: 0 }), +})); + +import { queryTransfers, queryByTxHash } from "../db"; + +const mockQueryTransfers = queryTransfers as jest.MockedFunction; +const mockQueryByTxHash = queryByTxHash as jest.MockedFunction; + +const ALICE = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; + +const transfer = (overrides: Partial = {}): TransferRecord => ({ + contractId: "CTOKEN", + eventType: "transfer", + fromAddress: null, + toAddress: ALICE, + amount: "10000000", + ledger: 100, + ledgerClosedAt: new Date("2025-01-01T00:00:00Z"), + txHash: "txhash", + eventId: "ev-1", + ...overrides, +}); + +const gql = (query: string, variables?: Record) => + request(createApp()) + .post("/graphql") + .set("Content-Type", "application/json") + .send({ query, variables }); + +describe("GraphQL network selection (#163)", () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + jest.clearAllMocks(); + process.env.NETWORKS = "testnet,mainnet"; + process.env.STELLAR_NETWORK = "testnet"; + mockQueryTransfers.mockResolvedValue({ total: 0, transfers: [], nextCursor: null } as never); + mockQueryByTxHash.mockResolvedValue([] as never); + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it("inherits the HTTP-level selector through the resolver context", async () => { + await gql(`{ transfers(address: "${ALICE}", direction: INCOMING) { total } }`).query({ + network: "mainnet", + }); + + expect(mockQueryTransfers).toHaveBeenCalledWith( + expect.objectContaining({ network: "mainnet" }), + ); + }); + + it("lets a field-level network argument override the request selector", async () => { + // So one document can compare both chains in a single round-trip. + await gql(`{ transfers(address: "${ALICE}", direction: INCOMING, network: MAINNET) { total } }`); + + expect(mockQueryTransfers).toHaveBeenCalledWith( + expect.objectContaining({ network: "mainnet" }), + ); + }); + + it("applies the argument to transferByTx too", async () => { + await gql(`{ transferByTx(txHash: "deadbeef", network: MAINNET) { eventId } }`); + + expect(mockQueryByTxHash).toHaveBeenCalledWith("deadbeef", "mainnet"); + }); + + it("defaults to the configured network when nothing is specified", async () => { + await gql(`{ transfers(address: "${ALICE}", direction: INCOMING) { total } }`); + + expect(mockQueryTransfers).toHaveBeenCalledWith( + expect.objectContaining({ network: "testnet" }), + ); + }); + + it("errors on a network the deployment does not serve", async () => { + process.env.NETWORKS = "testnet"; + + const res = await gql( + `{ transfers(address: "${ALICE}", direction: INCOMING, network: MAINNET) { total } }`, + ); + + expect(res.body.errors?.[0]?.message).toContain('Network "mainnet" is not enabled'); + expect(mockQueryTransfers).not.toHaveBeenCalled(); + }); + + it("rejects a value that is not in the Network enum at the schema level", async () => { + const res = await gql(`{ transfers(address: "${ALICE}", network: SOLANA) { total } }`); + + expect(res.body.errors?.[0]?.message).toMatch(/SOLANA/); + expect(mockQueryTransfers).not.toHaveBeenCalled(); + }); +}); + +describe("resolveSocketNetwork (#163)", () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + process.env.NETWORKS = "testnet,mainnet"; + process.env.STELLAR_NETWORK = "testnet"; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it("defaults to the configured network with no query string", () => { + expect(resolveSocketNetwork(`/subscribe/${ALICE}`)).toEqual({ network: "testnet" }); + }); + + it("reads ?network= off the upgrade URL", () => { + expect(resolveSocketNetwork(`/subscribe/${ALICE}?network=mainnet`)).toEqual({ + network: "mainnet", + }); + }); + + it("returns a close reason rather than a silent default for a bad value", () => { + // A subscriber cannot be told after the fact — they would sit on a socket + // that never delivers anything. + const result = resolveSocketNetwork(`/subscribe/${ALICE}?network=mainet`); + expect("error" in result && result.error).toContain('Invalid network: "mainet"'); + }); + + it("returns a close reason for a network the deployment does not serve", () => { + process.env.NETWORKS = "testnet"; + const result = resolveSocketNetwork(`/subscribe/${ALICE}?network=mainnet`); + expect("error" in result && result.error).toContain("is not enabled"); + }); +}); + +describe("WebSocket /subscribe network filter (#163)", () => { + let server: Server; + let url: string; + const originalEnv = { ...process.env }; + + beforeAll((done) => { + process.env.NETWORKS = "testnet,mainnet"; + process.env.STELLAR_NETWORK = "testnet"; + server = createServer(); + attachWebSocketServer(server); + server.listen(0, () => { + const addr = server.address(); + url = `ws://127.0.0.1:${typeof addr === "object" && addr ? addr.port : 0}`; + done(); + }); + }); + + afterAll((done) => { + process.env = { ...originalEnv }; + server.close(() => done()); + }); + + const connect = (path: string): Promise => + new Promise((resolve, reject) => { + const ws = new WebSocket(`${url}${path}`); + ws.on("open", () => resolve(ws)); + ws.on("error", reject); + }); + + it("delivers only the network the subscriber asked for", async () => { + const ws = await connect(`/subscribe/${ALICE}?network=mainnet`); + const received: string[] = []; + ws.on("message", (data) => received.push(JSON.parse(data.toString()).eventId)); + + // Both loops publish onto the same emitter in a dual-network process. + emitTransfer(transfer({ eventId: "testnet-row" }), "testnet"); + emitTransfer(transfer({ eventId: "mainnet-row" }), "mainnet"); + await new Promise((r) => setTimeout(r, 50)); + + expect(received).toEqual(["mainnet-row"]); + ws.close(); + }); + + it("defaults to the configured network when no selector is given", async () => { + const ws = await connect(`/subscribe/${ALICE}`); + const received: string[] = []; + ws.on("message", (data) => received.push(JSON.parse(data.toString()).eventId)); + + emitTransfer(transfer({ eventId: "testnet-row" }), "testnet"); + emitTransfer(transfer({ eventId: "mainnet-row" }), "mainnet"); + await new Promise((r) => setTimeout(r, 50)); + + expect(received).toEqual(["testnet-row"]); + ws.close(); + }); + + it("closes the socket with a reason on an invalid selector", async () => { + const ws = await connect(`/subscribe/${ALICE}?network=mainet`); + const closed = await new Promise<{ code: number; reason: string }>((resolve) => { + ws.on("close", (code, reason) => resolve({ code, reason: reason.toString() })); + }); + + expect(closed.code).toBe(1008); + expect(closed.reason).toContain('Invalid network: "mainet"'); + }); +}); diff --git a/src/__tests__/routes/search.test.ts b/src/__tests__/routes/search.test.ts index f9896629..623f1889 100644 --- a/src/__tests__/routes/search.test.ts +++ b/src/__tests__/routes/search.test.ts @@ -69,10 +69,10 @@ describe("GET /search", () => { await request(buildApp()).get("/search").query({ q: " gacc " }); expect(mockAccounts).toHaveBeenCalledWith( - expect.objectContaining({ where: { address: { startsWith: "GACC" } } }), + expect.objectContaining({ where: { network: "testnet", address: { startsWith: "GACC" } } }), ); expect(mockAssets).toHaveBeenCalledWith( - expect.objectContaining({ where: { contractId: { startsWith: "GACC" } } }), + expect.objectContaining({ where: { network: "testnet", contractId: { startsWith: "GACC" } } }), ); }); diff --git a/src/__tests__/routes/transfers.test.ts b/src/__tests__/routes/transfers.test.ts index b9fecf05..70996469 100644 --- a/src/__tests__/routes/transfers.test.ts +++ b/src/__tests__/routes/transfers.test.ts @@ -493,7 +493,7 @@ describe("Transfer route handlers", () => { await request(app).get("/transfers/tx/abc123def456"); - expect(mockQueryByTxHash).toHaveBeenCalledWith("abc123def456"); + expect(mockQueryByTxHash).toHaveBeenCalledWith("abc123def456", "testnet"); }); }); diff --git a/src/__tests__/webhooks.test.ts b/src/__tests__/webhooks.test.ts index e8cc7db7..22c4b496 100644 --- a/src/__tests__/webhooks.test.ts +++ b/src/__tests__/webhooks.test.ts @@ -78,6 +78,7 @@ const makeTransfer = (overrides: Partial = {}): TransferEvent => ledgerClosedAt: new Date("2024-01-01T00:00:00Z"), txHash: "abc123", eventId: "0001-0001", + network: "testnet", ...overrides, }); diff --git a/src/api.ts b/src/api.ts index 6425d252..1deaaeee 100644 --- a/src/api.ts +++ b/src/api.ts @@ -6,7 +6,7 @@ import { queryHostFnLogs } from "./indexer/host-fn-log"; import { queryTransfers, queryAllTransfers, queryByTxHash, querySummary, queryNftTransfers, getNftOwner, getNftMetadata, getLastIndexedLedger, prisma } from "./db"; import { getLatestLedger } from "./rpc"; import { getIndexerStats, getAllIndexerStats, runningNetworks } from "./indexer"; -import { enabledNetworks, type Network } from "./network"; +import { currentNetwork, enabledNetworks, type Network } from "./network"; import { createAccountsRouter } from "./api/accounts"; import { createWebhooksRouter } from "./api/webhooks"; import { createGraphQLMiddleware } from "./graphql/server"; @@ -24,29 +24,34 @@ import { transferQuerySchema, } from "./openapi/schemas"; import { parseOr400 } from "./openapi/validation"; +import { networkMiddleware, requestNetwork } from "./middleware/network"; import { renderMetrics, metricsContentType } from "./metrics"; // ─── RPC Health Check Cache ─────────────────────────────────────────────── -let cachedRpcHealth: { healthy: boolean; timestamp: number } | null = null; +// Keyed by network (#163): one cache entry would let a healthy testnet RPC +// mark mainnet requests fresh, or a mainnet outage mark testnet reads stale. +const cachedRpcHealth = new Map(); const RPC_HEALTH_CACHE_TTL_MS = 3000; export function clearRpcHealthCache(): void { - cachedRpcHealth = null; + cachedRpcHealth.clear(); } -export async function checkRpcHealth(): Promise { +export async function checkRpcHealth(network?: Network): Promise { + const net = network ?? currentNetwork(); const now = Date.now(); - if (cachedRpcHealth && now - cachedRpcHealth.timestamp < RPC_HEALTH_CACHE_TTL_MS) { - return cachedRpcHealth.healthy; + const cached = cachedRpcHealth.get(net); + if (cached && now - cached.timestamp < RPC_HEALTH_CACHE_TTL_MS) { + return cached.healthy; } try { - const latest = await getLatestLedger(); + const latest = await getLatestLedger(net); const healthy = typeof latest === "number" && latest > 0; - cachedRpcHealth = { healthy, timestamp: now }; + cachedRpcHealth.set(net, { healthy, timestamp: now }); return healthy; } catch { - cachedRpcHealth = { healthy: false, timestamp: now }; + cachedRpcHealth.set(net, { healthy: false, timestamp: now }); return false; } } @@ -125,6 +130,9 @@ export function createApp(): express.Application { app.use(cors()); app.use(express.json()); app.use(jsonApiMiddleware); + // Before every router: each request carries exactly one network, resolved and + // validated once here rather than re-parsed per handler (#163). + app.use(networkMiddleware); app.use(limiter); // ─── Stale read middleware ────────────────────────────────────────── @@ -138,9 +146,10 @@ export function createApp(): express.Application { } try { - const rpcHealthy = await checkRpcHealth(); + const net = requestNetwork(req); + const rpcHealthy = await checkRpcHealth(net); if (!rpcHealthy) { - const lastIndexed = await getLastIndexedLedger().catch(() => null); + const lastIndexed = await getLastIndexedLedger(net).catch(() => null); res.setHeader("X-Data-Stale", "true"); if (lastIndexed !== null) { res.setHeader("X-As-Of-Ledger", String(lastIndexed)); @@ -256,38 +265,63 @@ export function createApp(): express.Application { const parsed = parseOr400(readyzQuerySchema, _req.query, res); if (!parsed) return; const { maxLag } = parsed; - const checks: Record = {}; + const selected = requestNetwork(_req); + // The DB is one process-wide connection, so it is checked once rather than + // per network — a dead database is not a per-chain condition. + let dbUp: boolean; try { - // DB check await prisma.$queryRaw`SELECT 1`; - checks.db = true; + dbUp = true; } catch { - checks.db = false; + dbUp = false; } - let latest: number | null = null; - try { - // RPC check - latest = await getLatestLedger(); - checks.rpc = typeof latest === "number" && latest > 0; - } catch { - checks.rpc = false; - } + /** RPC reachability plus indexer lag for one network. */ + const checkNetwork = async (net: Network) => { + let latest: number | null = null; + let rpc = false; + try { + latest = await getLatestLedger(net); + rpc = typeof latest === "number" && latest > 0; + } catch { + rpc = false; + } - let lastIndexed: number | null = null; - try { - lastIndexed = await getLastIndexedLedger(); - const lag = (lastIndexed !== null && latest !== null) ? latest - lastIndexed : Infinity; - checks.indexerCaughtUp = lag <= maxLag; - } catch { - checks.indexerCaughtUp = false; - } + let lastIndexed: number | null = null; + let indexerCaughtUp = false; + try { + lastIndexed = await getLastIndexedLedger(net); + const lag = lastIndexed !== null && latest !== null ? latest - lastIndexed : Infinity; + indexerCaughtUp = lag <= maxLag; + } catch { + indexerCaughtUp = false; + } + + return { + checks: { db: dbUp, rpc, indexerCaughtUp }, + lastIndexedLedger: lastIndexed, + latestLedger: latest, + lagLedgers: latest !== null && lastIndexed !== null ? latest - lastIndexed : null, + }; + }; + // Report every enabled network, not just the selected one: an orchestrator + // probing readiness needs to see one chain falling behind while the other + // is fine, which a single merged verdict cannot express (#163). + const networks = enabledNetworks(); + const results = await Promise.all(networks.map(async (net) => [net, await checkNetwork(net)] as const)); + const byNetwork = Object.fromEntries(results); + + // Top-level fields describe the selected network so existing single-network + // consumers see exactly the shape they saw before. + const selectedResult = byNetwork[selected] ?? (await checkNetwork(selected)); + const checks = selectedResult.checks; + const lastIndexed = selectedResult.lastIndexedLedger; const allHealthy = Object.values(checks).every(Boolean); if (!checks.db) { - res.status(503).json({ ok: false, status: "down", checks }); + res.status(503).json({ ok: false, status: "down", network: selected, checks, networks: byNetwork }); } else { const status = allHealthy ? "healthy" : "degraded"; if (!allHealthy) { @@ -299,7 +333,9 @@ export function createApp(): express.Application { res.json({ ok: true, status, + network: selected, checks, + networks: byNetwork, ...(lastIndexed !== null ? { as_of_ledger: lastIndexed } : {}), ...(allHealthy ? {} : { stale: true }), }); @@ -310,11 +346,12 @@ export function createApp(): express.Application { /** * Returns indexer health status: "healthy", "degraded", or "down". */ - app.get("/status", async (_req: Request, res: Response, next: NextFunction) => { + app.get("/status", async (req: Request, res: Response, next: NextFunction) => { try { + const selected = requestNetwork(req); const [lastIndexedResult, latestLedgerResult] = await Promise.allSettled([ - getLastIndexedLedger(), - getLatestLedger(), + getLastIndexedLedger(selected), + getLatestLedger(selected), ]); if (lastIndexedResult.status === "rejected") { @@ -325,7 +362,7 @@ export function createApp(): express.Application { const lastIndexedLedger = lastIndexedResult.value; const rpcSuccess = latestLedgerResult.status === "fulfilled" && typeof latestLedgerResult.value === "number"; const latestLedger = rpcSuccess ? latestLedgerResult.value : null; - const stats = getIndexerStats(); + const stats = getIndexerStats(selected); // Per-network progress (#161). The top-level fields above stay as they // were so existing consumers are unaffected; this reports each loop @@ -364,6 +401,9 @@ export function createApp(): express.Application { res.json({ ok: true, status: "healthy", + // Which network the top-level fields describe. `networks` below + // reports every loop regardless of the selector. + network: selected, lastIndexedLedger, // snake_case alias alongside the camelCase field, for consumers that // read the same name the metric is exported under. Both always agree. @@ -381,6 +421,7 @@ export function createApp(): express.Application { res.json({ ok: true, status: "degraded", + network: selected, stale: true, as_of_ledger: lastIndexedLedger ?? undefined, lastIndexedLedger, @@ -436,6 +477,7 @@ export function createApp(): express.Application { }; const result = await queryTransfers({ + network: requestNetwork(req), address, direction: "incoming", contractId, @@ -497,6 +539,7 @@ export function createApp(): express.Application { }; const result = await queryTransfers({ + network: requestNetwork(req), address, direction: "outgoing", contractId, @@ -569,6 +612,7 @@ export function createApp(): express.Application { }; const result = await queryAllTransfers({ + network: requestNetwork(req), address, contractId, token, @@ -640,6 +684,7 @@ export function createApp(): express.Application { // Always fetch with offset=0 and enforce a 10,000 row limit for CSV export const result = await queryAllTransfers({ + network: requestNetwork(req), address, contractId, token, @@ -703,7 +748,7 @@ export function createApp(): express.Application { try { const parsed = parseOr400(txHashParamsSchema, req.params, res); if (!parsed) return; - const transfers = await queryByTxHash(parsed.txHash); + const transfers = await queryByTxHash(parsed.txHash, requestNetwork(req)); res.json({ transfers: transfers.map(withDisplay) }); } catch (err) { next(err); @@ -732,6 +777,7 @@ export function createApp(): express.Application { const { address, contractId, fromDate, toDate } = parsed; const rows = await querySummary({ + network: requestNetwork(req), address, contractId, fromDate, @@ -791,6 +837,7 @@ export function createApp(): express.Application { const { contractId, functionName, limit, offset } = parsed; const { total, logs } = await queryHostFnLogs({ + network: requestNetwork(req), contractId, functionName, limit, @@ -849,6 +896,7 @@ export function createApp(): express.Application { }; const result = await queryNftTransfers({ + network: requestNetwork(req), contractId: contract, tokenId: token_id, address, @@ -888,9 +936,10 @@ export function createApp(): express.Application { if (!parsed) return; const { contract, token_id } = parsed; + const network = requestNetwork(req); const [owner, metadata] = await Promise.all([ - getNftOwner(contract, token_id), - getNftMetadata(contract, token_id), + getNftOwner(contract, token_id, network), + getNftMetadata(contract, token_id, network), ]); if (owner === null) { diff --git a/src/api/accounts.ts b/src/api/accounts.ts index 4d72c60b..4701dca8 100644 --- a/src/api/accounts.ts +++ b/src/api/accounts.ts @@ -4,6 +4,7 @@ import { toDisplayAmount } from "../api"; import { createAccountsTransfersRouter } from "../routes/accounts/transfers"; import { parseOr400 } from "../openapi/validation"; import { summaryQuerySchema } from "../openapi/schemas"; +import { requestNetwork } from "../middleware/network"; type AccountSummaryRow = Awaited>[number]; @@ -36,7 +37,7 @@ export function createAccountsRouter(): Router { if (!parsed) return; const { address, contractId } = parsed; - const rows = await getAccountSummary(address, contractId); + const rows = await getAccountSummary(address, contractId, requestNetwork(req)); const assets = rows.map((row: AccountSummaryRow) => { const net = BigInt(row.net); diff --git a/src/events.ts b/src/events.ts index 22683db5..43a56c17 100644 --- a/src/events.ts +++ b/src/events.ts @@ -1,6 +1,7 @@ import { EventEmitter } from "events"; import type { TransferRecord } from "./db"; import type { HostFnRecord } from "./indexer/host-fn-log"; +import type { Network } from "./network"; // Singleton emitter for real-time transfer notifications. // setMaxListeners(0) removes the default 10-listener cap — one listener per @@ -8,10 +9,18 @@ import type { HostFnRecord } from "./indexer/host-fn-log"; export const transferEmitter = new EventEmitter(); transferEmitter.setMaxListeners(0); -export type TransferEvent = TransferRecord; +/** + * A transfer as broadcast to live subscribers. + * + * Carries the network it was indexed on (#163). `TransferRecord` does not have + * the column — the indexer adds it at write time — but a subscriber filtering + * by network has nothing else to filter on, and a process indexing both chains + * would otherwise push mainnet transfers to a testnet subscriber. + */ +export type TransferEvent = TransferRecord & { network: Network }; -export function emitTransfer(transfer: TransferEvent): void { - transferEmitter.emit("transfer:new", transfer); +export function emitTransfer(transfer: TransferRecord, network: Network): void { + transferEmitter.emit("transfer:new", { ...transfer, network }); } // Singleton emitter for real-time host-fn log notifications (GraphQL @@ -19,10 +28,10 @@ export function emitTransfer(transfer: TransferEvent): void { export const hostFnLogEmitter = new EventEmitter(); hostFnLogEmitter.setMaxListeners(0); -export type HostFnLogEvent = HostFnRecord; +export type HostFnLogEvent = HostFnRecord & { network: Network }; -export function emitHostFnLog(log: HostFnLogEvent): void { - hostFnLogEmitter.emit("hostfnlog:new", log); +export function emitHostFnLog(log: HostFnRecord, network: Network): void { + hostFnLogEmitter.emit("hostfnlog:new", { ...log, network }); } /** diff --git a/src/graphql/__tests__/subscriptions.test.ts b/src/graphql/__tests__/subscriptions.test.ts index a71ae65b..8e5b5857 100644 --- a/src/graphql/__tests__/subscriptions.test.ts +++ b/src/graphql/__tests__/subscriptions.test.ts @@ -46,6 +46,7 @@ function makeHostFnLog(overrides: Partial = {}): HostFnLogEvent ledgerClosedAt: new Date("2025-01-01T00:00:00Z"), txHash: "txhash", eventId: "hfl-1", + network: "testnet", ...overrides, }; } @@ -143,7 +144,7 @@ describe("GraphQL subscriptions /graphql/subscriptions", () => { await new Promise((r) => setTimeout(r, 20)); const pending = collectNext(ws, "1", 1); - emitTransfer(makeTransfer({ eventId: "ev-a" })); + emitTransfer(makeTransfer({ eventId: "ev-a" }), "testnet"); const [msg] = await pending; const transfer = msg.transferAdded as Record; @@ -165,8 +166,8 @@ describe("GraphQL subscriptions /graphql/subscriptions", () => { const pending = collectNext(ws, "2", 1); - emitTransfer(makeTransfer({ contractId: "COTHER", eventId: "ev-skip" })); - emitTransfer(makeTransfer({ contractId: "CWANTED", eventId: "ev-match" })); + emitTransfer(makeTransfer({ contractId: "COTHER", eventId: "ev-skip" }), "testnet"); + emitTransfer(makeTransfer({ contractId: "CWANTED", eventId: "ev-match" }), "testnet"); const [msg] = await pending; expect((msg.transferAdded as Record).eventId).toBe("ev-match"); @@ -180,7 +181,7 @@ describe("GraphQL subscriptions /graphql/subscriptions", () => { await new Promise((r) => setTimeout(r, 20)); const pending = collectNext(ws, "3", 1); - emitHostFnLog(makeHostFnLog({ eventId: "hfl-a" })); + emitHostFnLog(makeHostFnLog({ eventId: "hfl-a" }), "testnet"); const [msg] = await pending; expect((msg.hostFnLogAdded as Record).eventId).toBe("hfl-a"); @@ -203,7 +204,7 @@ describe("GraphQL subscriptions /graphql/subscriptions", () => { ws.send(JSON.stringify({ id: "4", type: "complete" })); await new Promise((r) => setTimeout(r, 20)); - emitTransfer(makeTransfer({ eventId: "ev-after-complete" })); + emitTransfer(makeTransfer({ eventId: "ev-after-complete" }), "testnet"); await new Promise((r) => setTimeout(r, 30)); expect(received).toBe(0); @@ -219,7 +220,7 @@ describe("GraphQL subscriptions /graphql/subscriptions", () => { const pending = collectNext(ws, "5", COUNT); for (let i = 0; i < COUNT; i++) { - emitTransfer(makeTransfer({ eventId: `bp-${i}` })); + emitTransfer(makeTransfer({ eventId: `bp-${i}` }), "testnet"); } const msgs = await pending; diff --git a/src/graphql/server.ts b/src/graphql/server.ts index d329ac34..c45de7c4 100644 --- a/src/graphql/server.ts +++ b/src/graphql/server.ts @@ -8,8 +8,15 @@ import { } from "../db"; import { costLimitPlugin } from "./costLimit"; import { persistedQueryPlugin } from "./persisted"; +import { requestNetwork } from "../middleware/network"; +import { enabledNetworks, isNetwork, NETWORKS, currentNetwork, type Network } from "../network"; export const typeDefs = `#graphql + enum Network { + TESTNET + MAINNET + } + enum TransferDirection { INCOMING OUTGOING @@ -55,15 +62,52 @@ export const typeDefs = `#graphql address: String! direction: TransferDirection = ALL contractId: String + network: Network limit: Int = 50 offset: Int = 0 ): TransferConnection! - transferByTx(txHash: String!): [Transfer!]! - summary(address: String!, contractId: String): [TokenSummary!]! + transferByTx(txHash: String!, network: Network): [Transfer!]! + summary(address: String!, contractId: String, network: Network): [TokenSummary!]! } `; type TransferDirection = "INCOMING" | "OUTGOING" | "ALL"; +type NetworkArg = "TESTNET" | "MAINNET"; + +/** What every resolver receives: the network the HTTP request selected (#163). */ +export interface GraphQLContext { + network: Network; +} + +/** + * Decide which network one field reads from. + * + * A field-level `network:` argument wins over the request-level selector, so a + * single document can compare two chains in one round-trip. With neither, the + * context's network applies — which is the `?network=` / `X-Network` value, and + * failing that the process default. + * + * An argument naming a network this deployment does not serve throws rather + * than returning nothing: an empty list would read as "no such transfers" + * instead of "this process has never indexed that chain". + */ +function resolveArgNetwork(arg: NetworkArg | undefined, ctx: GraphQLContext | undefined): Network { + if (arg === undefined) return ctx?.network ?? currentNetwork(); + + const normalised = arg.toLowerCase(); + if (!isNetwork(normalised)) { + throw new Error(`Invalid network: "${arg}". Valid values: ${NETWORKS.join(", ")}.`); + } + + const enabled = enabledNetworks(); + if (!enabled.includes(normalised)) { + throw new Error( + `Network "${normalised}" is not enabled on this deployment. Enabled networks: ${enabled.join(", ")}.` + ); + } + + return normalised; +} export function formatTransfer(row: Record) { return { @@ -85,11 +129,14 @@ export const resolvers = { address: string; direction: TransferDirection; contractId?: string; + network?: NetworkArg; limit?: number; offset?: number; - } + }, + ctx?: GraphQLContext ) => { const common = { + network: resolveArgNetwork(args.network, ctx), address: args.address, contractId: args.contractId, limit: args.limit, @@ -111,8 +158,12 @@ export const resolvers = { }; }, - transferByTx: async (_parent: unknown, args: { txHash: string }) => { - const transfers = await queryByTxHash(args.txHash); + transferByTx: async ( + _parent: unknown, + args: { txHash: string; network?: NetworkArg }, + ctx?: GraphQLContext + ) => { + const transfers = await queryByTxHash(args.txHash, resolveArgNetwork(args.network, ctx)); return (transfers as Array>).map((transfer) => formatTransfer(transfer) ); @@ -120,9 +171,14 @@ export const resolvers = { summary: async ( _parent: unknown, - args: { address: string; contractId?: string } + args: { address: string; contractId?: string; network?: NetworkArg }, + ctx?: GraphQLContext ) => { - const rows = await querySummary(args); + const rows = await querySummary({ + address: args.address, + contractId: args.contractId, + network: resolveArgNetwork(args.network, ctx), + }); return rows.map((row) => { const received = BigInt(row.totalReceived); const sent = BigInt(row.totalSent); @@ -145,7 +201,7 @@ function readPositiveInt(name: string, fallback: number): number { } export function createGraphQLMiddleware() { - const server = new ApolloServer({ + const server = new ApolloServer({ typeDefs, resolvers, persistedQueries: false, @@ -160,5 +216,10 @@ export function createGraphQLMiddleware() { server.startInBackgroundHandlingStartupErrorsByLoggingAndFailingAllRequests(); - return expressMiddleware(server); + // The HTTP-level selector reaches resolvers through the context, so + // `?network=` and `X-Network` work on /graphql exactly as on the REST routes + // — the networkMiddleware has already validated it by the time we read it. + return expressMiddleware(server, { + context: async ({ req }) => ({ network: requestNetwork(req) }), + }); } diff --git a/src/graphql/subscriptions.ts b/src/graphql/subscriptions.ts index b9d9cff2..0ef87f5d 100644 --- a/src/graphql/subscriptions.ts +++ b/src/graphql/subscriptions.ts @@ -27,7 +27,7 @@ import { } from "graphql"; import { WebSocketServer, WebSocket } from "ws"; import type { IncomingMessage, Server } from "http"; -import { typeDefs as baseTypeDefs, resolvers as baseResolvers, formatTransfer } from "./server"; +import { typeDefs as baseTypeDefs, resolvers as baseResolvers, formatTransfer, type GraphQLContext } from "./server"; import { toDisplayAmount } from "../api"; import { transferEmitter, @@ -37,6 +37,8 @@ import { type TransferEvent, type HostFnLogEvent, } from "../events"; +import { resolveSocketNetwork } from "../ws"; +import type { Network } from "../network"; export const SUBSCRIPTIONS_PATH = "/graphql/subscriptions"; @@ -59,11 +61,22 @@ const subscriptionTypeDefs = `#graphql } type Subscription { - transferAdded(contractId: String): Transfer! - hostFnLogAdded(contractId: String): HostFnLog! + transferAdded(contractId: String, network: Network): Transfer! + hostFnLogAdded(contractId: String, network: Network): HostFnLog! } `; +/** + * The network one subscription streams: its own `network:` argument, else the + * one the socket connected with. Values reaching here are already validated — + * the argument by the schema enum, the socket's by {@link resolveSocketNetwork} + * at upgrade time. + */ +function subscriptionNetwork(arg: string | undefined, ctx: GraphQLContext | undefined): Network { + if (arg !== undefined) return arg.toLowerCase() as Network; + return ctx?.network ?? "testnet"; +} + function formatHostFnLog(log: HostFnLogEvent) { return { ...log, @@ -76,8 +89,11 @@ function formatHostFnLog(log: HostFnLogEvent) { type FieldResolverMap = Record< string, - | GraphQLFieldResolver - | { subscribe?: GraphQLFieldResolver; resolve?: GraphQLFieldResolver } + | GraphQLFieldResolver + | { + subscribe?: GraphQLFieldResolver; + resolve?: GraphQLFieldResolver; + } >; /** @@ -113,11 +129,15 @@ function buildSubscriptionSchema(): GraphQLSchema { attachResolvers(schema, { Subscription: { transferAdded: { - subscribe: (_parent, args: { contractId?: string }) => - filterAsyncIterator( + subscribe: (_parent, args: { contractId?: string; network?: string }, ctx) => { + // Both loops publish onto one emitter, so the stream must be filtered + // by network or a subscriber gets the other chain's rows (#163). + const net = subscriptionNetwork(args.network, ctx); + return filterAsyncIterator( eventsToAsyncIterator(transferEmitter, "transfer:new"), - (t) => !args.contractId || t.contractId === args.contractId - ), + (t) => t.network === net && (!args.contractId || t.contractId === args.contractId) + ); + }, resolve: (payload: unknown) => { const transfer = payload as TransferEvent; return { @@ -127,11 +147,13 @@ function buildSubscriptionSchema(): GraphQLSchema { }, }, hostFnLogAdded: { - subscribe: (_parent, args: { contractId?: string }) => - filterAsyncIterator( + subscribe: (_parent, args: { contractId?: string; network?: string }, ctx) => { + const net = subscriptionNetwork(args.network, ctx); + return filterAsyncIterator( eventsToAsyncIterator(hostFnLogEmitter, "hostfnlog:new"), - (l) => !args.contractId || l.contractId === args.contractId - ), + (l) => l.network === net && (!args.contractId || l.contractId === args.contractId) + ); + }, resolve: (payload: unknown) => formatHostFnLog(payload as HostFnLogEvent), }, }, @@ -172,7 +194,18 @@ export function attachGraphQLSubscriptions(server: Server): void { }); }); - wss.on("connection", (ws: WebSocket) => { + wss.on("connection", (ws: WebSocket, req: IncomingMessage) => { + // `?network=` on the upgrade URL applies to every subscription on this + // socket unless a field overrides it. Rejected here rather than per + // subscription: a socket opened against a network this process does not + // serve can never produce anything. + const selection = resolveSocketNetwork(req?.url ?? ""); + if ("error" in selection) { + ws.close(1008, selection.error); + return; + } + const context: GraphQLContext = { network: selection.network }; + // One entry per active subscription id on this connection, so a // "complete" message or socket close can release its async iterator. const active = new Map>(); @@ -222,7 +255,12 @@ export function attachGraphQLSubscriptions(server: Server): void { return; } - const result = await subscribe({ schema, document, variableValues: variables }); + const result = await subscribe({ + schema, + document, + variableValues: variables, + contextValue: context, + }); if (!(Symbol.asyncIterator in result)) { send({ diff --git a/src/indexer.ts b/src/indexer.ts index c648d6aa..d13ae3b1 100644 --- a/src/indexer.ts +++ b/src/indexer.ts @@ -258,7 +258,7 @@ async function pollOnce( // Broadcast each new record to WebSocket subscribers if (inserted > 0) { - records.forEach(emitTransfer); + records.forEach((record) => emitTransfer(record, net)); } // Log every event as a raw host-fn invocation for downstream consumers (#84) @@ -269,7 +269,7 @@ async function pollOnce( await upsertHostFnLogs(hostFnRecords, net).catch((err: unknown) => console.error(`[indexer/${net}] host-fn log error:`, err), ); - hostFnRecords.forEach(emitHostFnLog); + hostFnRecords.forEach((record) => emitHostFnLog(record, net)); } // ── NFT path ───────────────────────────────────────────────────────────────── diff --git a/src/indexer/host-fn-log.ts b/src/indexer/host-fn-log.ts index c4f18fa8..084d4a80 100644 --- a/src/indexer/host-fn-log.ts +++ b/src/indexer/host-fn-log.ts @@ -131,6 +131,7 @@ export async function upsertHostFnLogs( } export type HostFnQueryParams = { + network?: Network; contractId: string; functionName?: string; limit?: number; @@ -144,10 +145,11 @@ export type HostFnQueryParams = { export async function queryHostFnLogs( params: HostFnQueryParams, ): Promise<{ total: number; logs: HostFnRecord[] }> { - const { contractId, functionName, limit = 50, offset = 0 } = params; + const { network, contractId, functionName, limit = 50, offset = 0 } = params; const cap = Math.min(limit, 200); const where = { + network: resolveNetwork(network), contractId, ...(functionName ? { functionName } : {}), }; diff --git a/src/indexer/parallel.ts b/src/indexer/parallel.ts index c134987b..e09d930a 100644 --- a/src/indexer/parallel.ts +++ b/src/indexer/parallel.ts @@ -66,7 +66,7 @@ async function runPartitionWorker( const inserted = await upsertTransfers(records, network); if (inserted > 0) { - records.forEach(emitTransfer); + records.forEach((record) => emitTransfer(record, network)); } return { inserted, highestLedger }; diff --git a/src/middleware/network.ts b/src/middleware/network.ts new file mode 100644 index 00000000..94935603 --- /dev/null +++ b/src/middleware/network.ts @@ -0,0 +1,106 @@ +/** + * Per-request network selection (#163). + * + * Storage, RPC and the indexer loops are already network-aware (#159–#161): + * every `db.ts` function takes an optional `network`, and omitting it means + * "whatever `STELLAR_NETWORK` says". The API had no way to say anything else, + * so a process indexing both chains could only ever serve one of them. + * + * This middleware resolves the network once per request and hangs it off + * `req.network`. Handlers pass that straight through to the data layer instead + * of each re-parsing the query string — one parse, one validation, one error + * message, and no route that quietly forgets to look. + */ +import type { Request, Response, NextFunction } from "express"; +import { currentNetwork, enabledNetworks, isNetwork, NETWORKS, type Network } from "../network"; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Express { + interface Request { + /** + * The network this request reads from. Always set by + * {@link networkMiddleware} before any route runs. + */ + network?: Network; + } + } +} + +/** Header form of the selector, for clients that would rather not touch the query string. */ +export const NETWORK_HEADER = "x-network"; + +/** + * Read the raw selector off a request: `?network=` first, then `X-Network`. + * + * Query wins over header so a link someone can paste and share beats a default + * their HTTP client set for them. + */ +function rawSelector(req: Request): string | undefined { + const fromQuery = req.query?.network; + const value = Array.isArray(fromQuery) ? fromQuery[0] : fromQuery; + if (typeof value === "string" && value.trim() !== "") return value; + + const fromHeader = req.headers?.[NETWORK_HEADER]; + const header = Array.isArray(fromHeader) ? fromHeader[0] : fromHeader; + if (typeof header === "string" && header.trim() !== "") return header; + + return undefined; +} + +/** + * Resolve `?network=` / `X-Network` into `req.network`, or answer 400. + * + * Absent selector → {@link currentNetwork}, which is exactly what every route + * read before this existed, so existing callers see no change. + * + * Two distinct rejections, because they need different fixes: + * - not a network at all ("mainet") → the caller has a typo; + * - a real network this process does not serve → the caller wants a + * deployment that indexes it, and the message says which ones this one has. + * + * Returning empty results for an un-indexed network would be the worse answer: + * "no transfers" and "this process has never looked at that chain" are not the + * same statement, and silently conflating them is how a dashboard ends up + * confidently showing zero. + */ +export function networkMiddleware(req: Request, res: Response, next: NextFunction): void { + const raw = rawSelector(req); + + if (raw === undefined) { + req.network = currentNetwork(); + next(); + return; + } + + const normalised = raw.trim().toLowerCase(); + + if (!isNetwork(normalised)) { + res.status(400).json({ + error: `Invalid network: "${raw}". Valid values: ${NETWORKS.join(", ")}.`, + }); + return; + } + + const enabled = enabledNetworks(); + if (!enabled.includes(normalised)) { + res.status(400).json({ + error: + `Network "${normalised}" is not enabled on this deployment. ` + + `Enabled networks: ${enabled.join(", ")}.`, + }); + return; + } + + req.network = normalised; + next(); +} + +/** + * The network for a request, for handlers that would rather not repeat the + * `?? currentNetwork()` fallback. The fallback only fires if a router is + * mounted without {@link networkMiddleware} ahead of it. + */ +export function requestNetwork(req: Request): Network { + return req.network ?? currentNetwork(); +} diff --git a/src/openapi/build.ts b/src/openapi/build.ts index faca3034..54bdddec 100644 --- a/src/openapi/build.ts +++ b/src/openapi/build.ts @@ -19,6 +19,7 @@ import { readyzResponseSchema, searchQuerySchema, searchResponseSchema, + statusQuerySchema, statusResponseSchema, summaryQuerySchema, summaryResponseSchema, @@ -93,6 +94,7 @@ registry.registerPath({ method: "get", path: "/status", summary: "Indexer status", + request: { query: statusQuerySchema }, responses: { 200: { description: "OK", content: { "application/json": { schema: statusResponseSchema } } }, ...commonErrorResponses, diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index e10d58b5..1849a770 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -133,6 +133,33 @@ const eventTypeQuerySchema = z.preprocess( }).optional() ); +/** + * The network selector accepted by every read route (#163). + * + * Optional: omitting it means the deployment's configured network. Invalid or + * un-enabled values are rejected by the network middleware before a handler + * runs, so this exists to document and type the parameter, not to validate it. + */ +export const networkQuerySchema = z.preprocess( + (value) => { + const raw = firstValue(value); + if (raw === undefined || raw === null || raw === "") return undefined; + if (typeof raw !== "string") return raw; + return raw.trim().toLowerCase(); + }, + z + .enum(["testnet", "mainnet"]) + .optional() + .openapi({ + description: + "Network to read from. Defaults to the deployment's configured network. " + + "May also be sent as the X-Network header; the query parameter wins.", + example: "mainnet", + }) +); + +const withNetwork = { network: networkQuerySchema }; + export const errorResponseSchema = z.object({ error: z.string(), }); @@ -146,17 +173,35 @@ export const healthzResponseSchema = z.object({ uptime: z.number(), }); +const readyzChecksSchema = z.object({ + db: z.boolean(), + rpc: z.boolean(), + indexerCaughtUp: z.boolean(), +}); + export const readyzResponseSchema = z.object({ ok: z.boolean(), - checks: z.object({ - db: z.boolean(), - rpc: z.boolean(), - indexerCaughtUp: z.boolean(), - }), + /** The network the top-level fields describe. */ + network: z.enum(["testnet", "mainnet"]).optional(), + checks: readyzChecksSchema, + /** One entry per enabled network, so one chain can be behind while another is fine. */ + networks: z + .record( + z.string(), + z.object({ + checks: readyzChecksSchema, + lastIndexedLedger: z.number().int().nullable(), + latestLedger: z.number().int().nullable(), + lagLedgers: z.number().int().nullable(), + }) + ) + .optional(), }); export const statusResponseSchema = z.object({ ok: z.literal(true), + /** The network the top-level fields describe; `networks` reports every loop. */ + network: z.enum(["testnet", "mainnet"]).optional(), lastIndexedLedger: z.number().int().nullable(), latestLedger: z.number().int(), lagLedgers: z.number().int(), @@ -351,6 +396,7 @@ export const popularAssetsResponseSchema = z.object({ }); export const searchQuerySchema = z.object({ + ...withNetwork, q: z .string() .trim() @@ -395,6 +441,7 @@ export const candleRefreshResponseSchema = z.object({ }); export const transferQuerySchema = z.object({ + ...withNetwork, address: stellarAddressSchema, contractId: optionalQueryString("Token contract ID to filter by"), token: contractAddressSchema.optional(), @@ -411,6 +458,7 @@ export const transferQuerySchema = z.object({ }).passthrough(); export const summaryQuerySchema = z.object({ + ...withNetwork, address: stellarAddressSchema, contractId: optionalQueryString("Token contract ID to filter by"), fromDate: optionalQueryDateTime("Inclusive lower bound on ledgerClosedAt"), @@ -422,6 +470,7 @@ export const txHashParamsSchema = z.object({ }).passthrough(); export const hostFnQuerySchema = z.object({ + ...withNetwork, contractId: contractAddressSchema, functionName: optionalQueryString("Host function name to filter by"), limit: queryIntWithDefault(50, { min: 1, max: 200, description: "Page size" }), @@ -429,6 +478,7 @@ export const hostFnQuerySchema = z.object({ }).passthrough(); export const nftTransfersQuerySchema = z.object({ + ...withNetwork, contract: optionalQueryString("NFT contract ID to filter by"), token_id: optionalQueryString("NFT token identifier"), address: optionalQueryString("Filter by sender or recipient address"), @@ -446,11 +496,15 @@ export const nftOwnerParamsSchema = z.object({ token_id: z.string().min(1), }).passthrough(); +export const statusQuerySchema = z.object({ ...withNetwork }).passthrough(); + export const readyzQuerySchema = z.object({ + ...withNetwork, maxLag: queryIntWithDefault(100, { min: 0, description: "Max acceptable ledger lag" }), }).passthrough(); export const popularAssetsQuerySchema = z.object({ + ...withNetwork, window: z.enum(["1h", "24h", "7d"]).default("24h"), by: z.enum(["transfers", "volume"]).default("transfers"), limit: queryIntWithDefault(20, { min: 1, max: 100, description: "Page size" }), diff --git a/src/routes/accounts/transfers.ts b/src/routes/accounts/transfers.ts index 91f9e6d5..790c5849 100644 --- a/src/routes/accounts/transfers.ts +++ b/src/routes/accounts/transfers.ts @@ -2,6 +2,7 @@ import { Router, Request, Response, NextFunction } from "express"; import { queryAllTransfers } from "../../db"; import { parseOr400 } from "../../openapi/validation"; import { transferQuerySchema } from "../../openapi/schemas"; +import { requestNetwork } from "../../middleware/network"; const VALID_EVENT_TYPES = new Set(["transfer", "mint", "burn", "clawback"]); const STROOPS = 10_000_000n; @@ -74,6 +75,7 @@ export function createAccountsTransfersRouter(): Router { }; const result = await queryAllTransfers({ + network: requestNetwork(req), address, contractId, token, diff --git a/src/routes/assets/popular.ts b/src/routes/assets/popular.ts index b8104a97..1806e8aa 100644 --- a/src/routes/assets/popular.ts +++ b/src/routes/assets/popular.ts @@ -2,6 +2,7 @@ import { Router, Request, Response, NextFunction } from "express"; import { queryPopularAssets, toDisplayAmount } from "../../db"; import { parseOr400 } from "../../openapi/validation"; import { popularAssetsQuerySchema } from "../../openapi/schemas"; +import { requestNetwork } from "../../middleware/network"; const VALID_WINDOWS = new Set(["1h", "24h", "7d"]); const VALID_SORT_BY = new Set(["transfers", "volume"]); @@ -32,7 +33,13 @@ export function createPopularAssetsRouter(): Router { const { window, by, limit, offset } = parsed; const fromDate = windowToDate(window); - const { total, assets } = await queryPopularAssets({ fromDate, by, limit, offset }); + const { total, assets } = await queryPopularAssets({ + network: requestNetwork(req), + fromDate, + by, + limit, + offset, + }); res.json({ window, diff --git a/src/routes/exports.ts b/src/routes/exports.ts index 0fd0844d..a09ac930 100644 --- a/src/routes/exports.ts +++ b/src/routes/exports.ts @@ -4,12 +4,14 @@ import { prisma, toDisplayAmount } from "../db"; import os from "os"; import path from "path"; import fs from "fs"; +import { requestNetwork } from "../middleware/network"; +import type { Network } from "../network"; // How many rows we fetch per DB round-trip. Keeps memory flat. const BATCH_SIZE = 500; // ── Shared: parse query params into a Prisma where clause ──────────────────── -function buildWhere(query: Record) { +function buildWhere(query: Record, network: Network) { const { address, contractId, @@ -20,7 +22,9 @@ function buildWhere(query: Record) { eventType, } = query; - const where: Record = {}; + // Network first: an export must not leak rows from a chain the caller did + // not ask for, and every filter below narrows within it. + const where: Record = { network }; if (address) { where.OR = [{ fromAddress: address }, { toAddress: address }]; @@ -70,7 +74,7 @@ async function* streamTransfers(where: Record) { // ── CSV endpoint ───────────────────────────────────────────────────────────── async function handleCsvExport(req: Request, res: Response, next: NextFunction) { try { - const where = buildWhere(req.query as Record); + const where = buildWhere(req.query as Record, requestNetwork(req)); res.setHeader("Content-Type", "text/csv"); res.setHeader("Content-Disposition", "attachment; filename=\"transfers.csv\""); @@ -112,7 +116,7 @@ async function handleParquetExport(req: Request, res: Response, next: NextFuncti const tmpFile = path.join(os.tmpdir(), `transfers-${Date.now()}-${Math.random().toString(36).slice(2)}.parquet`); try { - const where = buildWhere(req.query as Record); + const where = buildWhere(req.query as Record, requestNetwork(req)); const schema = new parquet.ParquetSchema({ id: { type: "INT64" }, diff --git a/src/routes/search.ts b/src/routes/search.ts index 4e001948..f6a31765 100644 --- a/src/routes/search.ts +++ b/src/routes/search.ts @@ -2,6 +2,7 @@ import { Router, Request, Response, NextFunction } from "express"; import { prisma } from "../db"; import { parseOr400 } from "../openapi/validation"; import { searchQuerySchema } from "../openapi/schemas"; +import { requestNetwork } from "../middleware/network"; // Top-10 hits across all types; fetch up to this many per type before merging. const MAX_RESULTS = 10; @@ -80,23 +81,25 @@ export function createSearchRouter(): Router { const q = normalizeQuery(parsed.q); + const network = requestNetwork(req); + const [accounts, assets, contracts] = await Promise.all([ prisma.accountSummary.findMany({ - where: { address: { startsWith: q } }, + where: { network, address: { startsWith: q } }, distinct: ["address"], orderBy: [{ address: "asc" }], take: PER_TYPE, select: { address: true, lastActivityAt: true }, }), prisma.tokenTransfer.findMany({ - where: { contractId: { startsWith: q } }, + where: { network, contractId: { startsWith: q } }, distinct: ["contractId"], orderBy: [{ contractId: "asc" }], take: PER_TYPE, select: { contractId: true, isSac: true }, }), prisma.hostFnLog.findMany({ - where: { contractId: { startsWith: q } }, + where: { network, contractId: { startsWith: q } }, distinct: ["contractId"], orderBy: [{ contractId: "asc" }], take: PER_TYPE, diff --git a/src/ws.ts b/src/ws.ts index 4f84bc15..b27af4a0 100644 --- a/src/ws.ts +++ b/src/ws.ts @@ -2,9 +2,38 @@ import { WebSocketServer, WebSocket } from "ws"; import type { IncomingMessage, Server } from "http"; import { transferEmitter, TransferEvent } from "./events"; import { toDisplayAmount } from "./api"; +import { currentNetwork, enabledNetworks, isNetwork, NETWORKS, type Network } from "./network"; -// Matches /subscribe/ -const SUBSCRIBE_RE = /^\/subscribe\/([A-Z0-9]+)$/; +// Matches /subscribe/, ignoring any query string (#163). +const SUBSCRIBE_RE = /^\/subscribe\/([A-Z0-9]+)(?:\?.*)?$/; + +/** + * Resolve `?network=` on the upgrade URL. + * + * Returns the selected network, or a rejection reason to close the socket + * with. A subscriber cannot be told "your filter was invalid" after the fact — + * they would just sit on a silent socket — so an unusable selector closes the + * connection with a message instead of quietly defaulting. + */ +export function resolveSocketNetwork(url: string): { network: Network } | { error: string } { + const query = url.includes("?") ? url.slice(url.indexOf("?") + 1) : ""; + const raw = new URLSearchParams(query).get("network"); + if (raw === null || raw.trim() === "") return { network: currentNetwork() }; + + const normalised = raw.trim().toLowerCase(); + if (!isNetwork(normalised)) { + return { error: `Invalid network: "${raw}". Valid values: ${NETWORKS.join(", ")}.` }; + } + + const enabled = enabledNetworks(); + if (!enabled.includes(normalised)) { + return { + error: `Network "${normalised}" is not enabled on this deployment. Enabled networks: ${enabled.join(", ")}.`, + }; + } + + return { network: normalised }; +} type WsPayload = TransferEvent & { displayAmount: string }; @@ -50,8 +79,19 @@ export function attachWebSocketServer(server: Server): void { } const address = match[1]; + const selection = resolveSocketNetwork(req.url ?? ""); + if ("error" in selection) { + // 1008 = policy violation, the closest close code for a bad parameter. + ws.close(1008, selection.error); + return; + } + const network = selection.network; + const handler = (transfer: TransferEvent) => { if (ws.readyState !== WebSocket.OPEN) return; + // A process indexing both chains emits both onto the same emitter, so a + // subscriber that asked for one must not be handed the other. + if (transfer.network !== network) return; if (transfer.toAddress !== address && transfer.fromAddress !== address) return; ws.send(JSON.stringify(buildPayload(transfer))); };