Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions docs/DUAL_NETWORK.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ behave exactly as before; #161 and #163 pass it explicitly.

| Var | testnet | mainnet |
|-----|---------|---------|
| `NETWORKS` | `testnet` | `testnet,mainnet` to index both in one process |
| `STELLAR_NETWORK` | `testnet` | `mainnet` |
| `SOROBAN_RPC_URL` | `https://soroban-testnet.stellar.org` | external provider endpoint (**secret — host env only**) |
| `SAC_CONTRACT_IDS` | testnet SAC `CDMLFMKM…` | mainnet XLM SAC `CDLZFC3SY…` |
Expand All @@ -33,15 +34,32 @@ behave exactly as before; #161 and #163 pass it explicitly.
> Mainnet has **no free public Soroban RPC** — an external provider endpoint is
> required. Never commit the endpoint/key; it lives only in host secrets.

### In-process dual-network (#160, #161)

Set `NETWORKS=testnet,mainnet` to run one indexer loop per network in a single
process. Each loop owns its cursor, counters, watch list, RPC client and source
switcher, so neither can stall or repoint the other, and `/status` reports both
under `networks`.

Every setting that names a chain takes a per-network suffix, falling back to the
shared name: `SOROBAN_RPC_URL_MAINNET`, `HORIZON_URL_MAINNET`,
`SAC_CONTRACT_IDS_MAINNET`, `NFT_CONTRACT_IDS_MAINNET`, `START_LEDGER_MAINNET`
(and the `_TESTNET` equivalents).

> The **unsuffixed** `SOROBAN_RPC_URL` applies only to the network named by
> `STELLAR_NETWORK`. That is deliberate: honouring it for both would let a
> mainnet loop connect to a testnet endpoint and write testnet ledgers tagged
> `network='mainnet'`. Indexing mainnet requires `SOROBAN_RPC_URL_MAINNET`.

## Ordered work (next Wave)

Dependencies: **#159 → #161** and #160 before #161.

| # | Issue | Dep |
|---|-------|-----|
| ~~[#159](../../issues/159)~~ | ~~`network` column across all Prisma models~~ (done) | — |
| [#160](../../issues/160) | Per-network `getRpc(network)` factory | — |
| [#161](../../issues/161) | One indexer loop per network | #159, #160 |
| ~~[#160](../../issues/160)~~ | ~~Per-network `getRpc(network)` factory~~ (done) | — |
| ~~[#161](../../issues/161)~~ | ~~One indexer loop per network~~ (done) | #159, #160 |
| [#162](../../issues/162) | Per-network SAC/NFT watch-lists | — |
| [#163](../../issues/163) | `network` selector on REST/GraphQL/WS | #159–#161 |
| [#164](../../issues/164) | Serve stale cached data instead of 503 | — |
Expand Down
4 changes: 4 additions & 0 deletions src/__tests__/accountSummary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ jest.mock("../rpc", () => ({
}));

jest.mock("../indexer", () => ({
// #161: /status also reads per-network loop state. Listed explicitly
// because a partial mock silently 500s the route rather than failing loudly.
getAllIndexerStats: jest.fn().mockReturnValue({}),
runningNetworks: jest.fn().mockReturnValue([]),
getIndexerStats: jest.fn().mockReturnValue({ startedAt: "2024-01-01T00:00:00Z", uptimeSeconds: 0, totalIndexed: 0 }),
}));

Expand Down
4 changes: 3 additions & 1 deletion src/__tests__/fetchEventsSafe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ describe('fetchEventsSafe — bisection algorithm', () => {

await fetchEventsSafe(100, 100, contracts, 5_000, fetch as any)

expect(fetch).toHaveBeenCalledWith(100, contracts, 5_000)
// fetchEventsSafe now forwards the network as a 4th argument (#161);
// undefined here means "the configured network", the single-network default.
expect(fetch).toHaveBeenCalledWith(100, contracts, 5_000, undefined)
})
})
4 changes: 4 additions & 0 deletions src/__tests__/graphql.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ jest.mock("../rpc", () => ({
}));

jest.mock("../indexer", () => ({
// #161: /status also reads per-network loop state. Listed explicitly
// because a partial mock silently 500s the route rather than failing loudly.
getAllIndexerStats: jest.fn().mockReturnValue({}),
runningNetworks: jest.fn().mockReturnValue([]),
getIndexerStats: jest.fn().mockReturnValue({ uptimeSeconds: 0, totalIndexed: 0 }),
}));

Expand Down
198 changes: 198 additions & 0 deletions src/__tests__/multiNetworkIndexer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
/**
* Multi-network indexer tests (#161, #160).
*
* The acceptance criteria are about *isolation*, and isolation bugs are quiet:
* two loops sharing a counter still index correctly, they just report nonsense;
* two loops sharing an RPC client still fetch events, just from one chain. So
* these assert that per-network things are actually distinct, rather than that
* the code runs.
*/

import {
DEFAULT_XLM_SAC_MAINNET,
DEFAULT_XLM_SAC_TESTNET,
getIndexerStats,
resolveNftContractIds,
resolveSacContractIds,
runningNetworks,
_resetIndexerLoops,
} from "../indexer";
import { getRpc, validateNetworkConfig, _resetRpcClients } from "../rpc";
import { currentNetwork, enabledNetworks, parseNetwork } from "../network";

const ENV_KEYS = [
"NETWORKS",
"STELLAR_NETWORK",
"SAC_CONTRACT_IDS",
"SAC_CONTRACT_IDS_TESTNET",
"SAC_CONTRACT_IDS_MAINNET",
"CONTRACT_IDS",
"NFT_CONTRACT_IDS",
"NFT_CONTRACT_IDS_TESTNET",
"NFT_CONTRACT_IDS_MAINNET",
"SOROBAN_RPC_URL",
"STELLAR_RPC_URL",
"SOROBAN_RPC_URL_TESTNET",
"SOROBAN_RPC_URL_MAINNET",
];

beforeEach(() => {
for (const key of ENV_KEYS) delete process.env[key];
_resetRpcClients();
_resetIndexerLoops();
});

describe("enabledNetworks", () => {
it("defaults to the single configured network, so existing deployments are unchanged", () => {
expect(enabledNetworks()).toEqual(["testnet"]);

process.env.STELLAR_NETWORK = "mainnet";
expect(enabledNetworks()).toEqual(["mainnet"]);
});

it("parses NETWORKS into an ordered list", () => {
process.env.NETWORKS = "testnet,mainnet";
expect(enabledNetworks()).toEqual(["testnet", "mainnet"]);
});

it("tolerates whitespace and case", () => {
process.env.NETWORKS = " MAINNET , testnet ";
expect(enabledNetworks()).toEqual(["mainnet", "testnet"]);
});

it("de-duplicates — two loops on one network would fight over the same cursor", () => {
process.env.NETWORKS = "testnet,testnet";
expect(enabledNetworks()).toEqual(["testnet"]);
});

it("drops unrecognised entries rather than starting a loop for them", () => {
process.env.NETWORKS = "testnet,futurenet";
expect(enabledNetworks()).toEqual(["testnet"]);
expect(parseNetwork("futurenet")).toBeNull();
});

it("falls back to the configured network when NETWORKS is empty or all junk", () => {
process.env.STELLAR_NETWORK = "mainnet";
process.env.NETWORKS = " , ";
expect(enabledNetworks()).toEqual(["mainnet"]);

process.env.NETWORKS = "nope,alsonope";
expect(enabledNetworks()).toEqual(["mainnet"]);
});
});

describe("per-network watch lists", () => {
it("defaults each network to its own native XLM SAC", () => {
// The bug this prevents: reading STELLAR_NETWORK inside the resolver, so
// both loops watch the same chain's SAC and one indexes nothing.
expect(resolveSacContractIds("testnet")).toEqual([DEFAULT_XLM_SAC_TESTNET]);
expect(resolveSacContractIds("mainnet")).toEqual([DEFAULT_XLM_SAC_MAINNET]);
expect(DEFAULT_XLM_SAC_TESTNET).not.toEqual(DEFAULT_XLM_SAC_MAINNET);
});

it("keeps the process-wide default when no network is passed", () => {
process.env.STELLAR_NETWORK = "mainnet";
expect(resolveSacContractIds()).toEqual([DEFAULT_XLM_SAC_MAINNET]);
});

it("prefers the per-network env var over the shared one", () => {
process.env.SAC_CONTRACT_IDS = "CSHARED";
process.env.SAC_CONTRACT_IDS_MAINNET = "CMAIN1,CMAIN2";

expect(resolveSacContractIds("mainnet")).toEqual(["CMAIN1", "CMAIN2"]);
// testnet has no override, so it still sees the shared value
expect(resolveSacContractIds("testnet")).toEqual(["CSHARED"]);
});

it("still honours the legacy CONTRACT_IDS alias", () => {
process.env.CONTRACT_IDS = "CLEGACY";
expect(resolveSacContractIds("testnet")).toEqual(["CLEGACY"]);
});

it("resolves NFT watch lists per network", () => {
process.env.NFT_CONTRACT_IDS = "CNFT_SHARED";
process.env.NFT_CONTRACT_IDS_TESTNET = "CNFT_T";

expect(resolveNftContractIds("testnet")).toEqual(["CNFT_T"]);
expect(resolveNftContractIds("mainnet")).toEqual(["CNFT_SHARED"]);
});
});

describe("per-network RPC clients (#160)", () => {
it("returns one cached client per network, and never shares between them", () => {
process.env.SOROBAN_RPC_URL_TESTNET = "https://testnet.example/rpc";
process.env.SOROBAN_RPC_URL_MAINNET = "https://mainnet.example/rpc";

const testnet = getRpc("testnet");
const mainnet = getRpc("mainnet");

// Same network → same instance (cached, so we don't open a pool per call)
expect(getRpc("testnet")).toBe(testnet);
// Different network → different instance. Sharing one was the whole bug.
expect(mainnet).not.toBe(testnet);
});

it("scopes the legacy unsuffixed SOROBAN_RPC_URL to the configured network only", () => {
// The dangerous case: a single-network deployment sets SOROBAN_RPC_URL for
// testnet, then enables mainnet. If the legacy var applied to both, the
// mainnet loop would connect to testnet RPC, index happily, and write
// testnet ledgers tagged network='mainnet'. It must fail loudly instead.
process.env.STELLAR_NETWORK = "testnet";
process.env.SOROBAN_RPC_URL = "https://testnet.example/rpc";

expect(() => getRpc("testnet")).not.toThrow();
expect(() => getRpc("mainnet")).toThrow(/SOROBAN_RPC_URL_MAINNET is required/);
});

it("still lets a single-network mainnet deployment use the unsuffixed var", () => {
process.env.STELLAR_NETWORK = "mainnet";
process.env.SOROBAN_RPC_URL = "https://mainnet.example/rpc";

expect(() => getRpc("mainnet")).not.toThrow();
});

it("defaults testnet to the public endpoint but refuses to guess for mainnet", () => {
// There is no free public mainnet Soroban RPC, so guessing would produce a
// client that fails on every call instead of a clear config error.
expect(() => getRpc("testnet")).not.toThrow();
expect(() => getRpc("mainnet")).toThrow(/no free public Soroban RPC|SOROBAN_RPC_URL_MAINNET/);
});

it("validateNetworkConfig checks every network it is given", () => {
process.env.SOROBAN_RPC_URL_TESTNET = "https://testnet.example/rpc";

expect(() => validateNetworkConfig(["testnet"])).not.toThrow();
// Fails at startup rather than after testnet has begun writing.
expect(() => validateNetworkConfig(["testnet", "mainnet"])).toThrow();
});
});

describe("loop state isolation", () => {
it("reports no running loops before any are started", () => {
expect(runningNetworks()).toEqual([]);
});

it("reports zero indexed for a network with no loop, rather than another network's total", () => {
// Guards the shape of the failure: an un-started loop must not inherit
// whatever the other loop has counted.
expect(getIndexerStats("mainnet").totalIndexed).toBe(0);
expect(getIndexerStats("testnet").totalIndexed).toBe(0);
});

it("getIndexerStats keeps its original shape for existing /status consumers", () => {
const stats = getIndexerStats();
expect(stats).toEqual({
startedAt: expect.any(String),
uptimeSeconds: expect.any(Number),
totalIndexed: expect.any(Number),
});
expect(new Date(stats.startedAt).toString()).not.toBe("Invalid Date");
});

it("resolves the default network from the environment on every call", () => {
process.env.STELLAR_NETWORK = "mainnet";
expect(currentNetwork()).toBe("mainnet");
process.env.STELLAR_NETWORK = "testnet";
expect(currentNetwork()).toBe("testnet");
});
});
4 changes: 4 additions & 0 deletions src/__tests__/routes/transfers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ jest.mock("../../rpc", () => ({
}));

jest.mock("../../indexer", () => ({
// #161: /status also reads per-network loop state. Listed explicitly
// because a partial mock silently 500s the route rather than failing loudly.
getAllIndexerStats: jest.fn().mockReturnValue({}),
runningNetworks: jest.fn().mockReturnValue([]),
getIndexerStats: jest
.fn()
.mockReturnValue({ startedAt: "2024-01-01T00:00:00.000Z", uptimeSeconds: 0, totalIndexed: 0 }),
Expand Down
20 changes: 20 additions & 0 deletions src/__tests__/staleReads.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ jest.mock("../rpc", () => ({
}));

jest.mock("../indexer", () => ({
// #161: /status also reads per-network loop state. Listed explicitly
// because a partial mock silently 500s the route rather than failing loudly.
getAllIndexerStats: jest.fn().mockReturnValue({}),
runningNetworks: jest.fn().mockReturnValue([]),
getIndexerStats: jest
.fn()
.mockReturnValue({ startedAt: "2024-01-01T00:00:00.000Z", uptimeSeconds: 100, totalIndexed: 50 }),
Expand Down Expand Up @@ -102,6 +106,13 @@ describe("Graceful stale reads during RPC outage (#164)", () => {
expect(res.body.lastIndexedLedger).toBe(1000);
expect(res.body.latestLedger).toBe(1050);
expect(res.body.lagLedgers).toBe(50);
// #161: per-network progress alongside the aggregate view.
expect(res.body.networks).toBeDefined();
expect(res.body.networks.testnet).toMatchObject({
lastIndexedLedger: 1000,
latestLedger: 1050,
lagLedgers: 50,
});
});

it("returns status 'degraded' when RPC is down but DB is healthy", async () => {
Expand All @@ -114,6 +125,15 @@ describe("Graceful stale reads during RPC outage (#164)", () => {
expect(res.body.status).toBe("degraded");
expect(res.body.stale).toBe(true);
expect(res.body.as_of_ledger).toBe(1000);
// Reported when degraded too. With two loops "RPC is down" is usually
// true of one chain only, and the top-level nulls cannot say which —
// so omitting this here would blind exactly the case it exists for.
expect(res.body.networks).toBeDefined();
expect(res.body.networks.testnet).toMatchObject({
lastIndexedLedger: 1000,
latestLedger: null,
lagLedgers: null,
});
expect(res.body.latestLedger).toBeNull();
expect(res.body.lagLedgers).toBeNull();
expect(res.headers["x-data-stale"]).toBe("true");
Expand Down
4 changes: 4 additions & 0 deletions src/__tests__/webhooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ jest.mock("../rpc", () => ({
}));

jest.mock("../indexer", () => ({
// #161: /status also reads per-network loop state. Listed explicitly
// because a partial mock silently 500s the route rather than failing loudly.
getAllIndexerStats: jest.fn().mockReturnValue({}),
runningNetworks: jest.fn().mockReturnValue([]),
getIndexerStats: jest.fn().mockReturnValue({
startedAt: "2024-01-01T00:00:00Z",
uptimeSeconds: 0,
Expand Down
Loading
Loading