From f426a820cc5ea21aedd766c73b398141744902af Mon Sep 17 00:00:00 2001 From: jayteemoney Date: Wed, 7 Oct 2026 14:19:53 +0100 Subject: [PATCH] fix(frontend): one network default, and stop at startup on a mismatched pair `npm run dev` reads no env file (only .env.production exists), so NETWORK fell back to testnet while CONTRACT_DEPLOYER fell back to the mainnet deployer. Since the curated list was scoped to the active network, that pair left it empty, DEFAULT_TOKEN was undefined, and the dashboard crashed in useTokenBalance ("Cannot read properties of undefined (reading 'contractId')"). The server code defaulted to mainnet, so the two halves of the app could also disagree about the chain. - The network now defaults to mainnet, matching the deployer default and the live site, so a bare `npm run dev` reads the production contracts. - An unknown network name, or a deployer from the other network, now stops the app at startup with a message naming the variable to fix, instead of failing later as an empty token list. - openclaw-server takes the network and deployer from constants, so the API and the UI cannot read different chains. - Adds frontend/.env.example documenting the pair. --- frontend/.env.example | 17 +++++++++ frontend/.gitignore | 1 + frontend/src/lib/constants.ts | 43 ++++++++++++++++++---- frontend/src/lib/openclaw-server.ts | 10 ++--- tests/network-config.test.ts | 57 +++++++++++++++++++++++++++++ tests/token-metadata-client.test.ts | 24 ++++++++++-- 6 files changed, 135 insertions(+), 17 deletions(-) create mode 100644 frontend/.env.example create mode 100644 tests/network-config.test.ts diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..5c3877e --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,17 @@ +# Copy to .env.local to override the defaults for `npm run dev`. +# +# With no env file the app runs against the live mainnet deployment, the same +# contracts as stackstream.xyz. Real wallets and real funds: test with small +# amounts. +# +# The network and the deployer are a pair. A mismatched pair (a testnet network +# with a mainnet SP… deployer, or the reverse) stops the app at startup. + +# mainnet (default) or testnet +NEXT_PUBLIC_NETWORK=mainnet +# The address that deployed stream-manager and stream-factory on that network +NEXT_PUBLIC_CONTRACT_DEPLOYER=SP2V6TCRFTYQHP8F4D9HSFZHRQNGVBQEZR0TMSM79 + +# For a testnet build, replace both lines above with your testnet deployment: +# NEXT_PUBLIC_NETWORK=testnet +# NEXT_PUBLIC_CONTRACT_DEPLOYER=ST…your-testnet-deployer diff --git a/frontend/.gitignore b/frontend/.gitignore index c9f56c7..30e9721 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -33,6 +33,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* !.env.production +!.env.example # vercel .vercel diff --git a/frontend/src/lib/constants.ts b/frontend/src/lib/constants.ts index 8e50806..f3d217d 100644 --- a/frontend/src/lib/constants.ts +++ b/frontend/src/lib/constants.ts @@ -2,16 +2,39 @@ // Network & Contract Configuration // ============================================================================ -export const NETWORK = (process.env.NEXT_PUBLIC_NETWORK ?? "testnet") as - | "testnet" - | "mainnet"; +/** + * The network and the contract deployer travel together: the contracts are + * deployed once per network, so a testnet build needs a testnet (ST/SN) + * deployer and a mainnet build a mainnet (SP/SM) one. + * + * Both default to the live deployment on mainnet, so `npm run dev` with no env + * file reads the same contracts as stackstream.xyz. A testnet build sets both + * variables (see .env.example). A mismatched pair used to start "fine" and then + * fail deep in the UI with an empty token list, so it now stops at startup + * with a message saying which variable to fix. + */ +const LIVE_DEPLOYER = "SP2V6TCRFTYQHP8F4D9HSFZHRQNGVBQEZR0TMSM79"; + +function readNetwork(value: string | undefined): "testnet" | "mainnet" { + if (!value) return "mainnet"; + if (value === "mainnet" || value === "testnet") return value; + throw new Error(`NEXT_PUBLIC_NETWORK must be "mainnet" or "testnet", not "${value}".`); +} + +export const NETWORK = readNetwork(process.env.NEXT_PUBLIC_NETWORK); export const IS_MAINNET = NETWORK === "mainnet"; // Contract deployer address -export const CONTRACT_DEPLOYER = - process.env.NEXT_PUBLIC_CONTRACT_DEPLOYER ?? - "SP2V6TCRFTYQHP8F4D9HSFZHRQNGVBQEZR0TMSM79"; +export const CONTRACT_DEPLOYER = process.env.NEXT_PUBLIC_CONTRACT_DEPLOYER || LIVE_DEPLOYER; + +const deployerNetwork = /^S[PM]/.test(CONTRACT_DEPLOYER) ? "mainnet" : "testnet"; +if (deployerNetwork !== NETWORK) { + throw new Error( + `NEXT_PUBLIC_CONTRACT_DEPLOYER (${CONTRACT_DEPLOYER}) is a ${deployerNetwork} address, ` + + `but NEXT_PUBLIC_NETWORK is ${NETWORK}. Set them as a pair; see frontend/.env.example.` + ); +} // Contract identifiers export const STREAM_MANAGER_CONTRACT = `${CONTRACT_DEPLOYER}.stream-manager`; @@ -153,7 +176,13 @@ export const SUPPORTED_TOKENS = getCuratedTokens(NETWORK); * stream — a stream in an unlisted token is not this token, and using it as a * fallback is what produced wrong asset names in post-conditions. */ -export const DEFAULT_TOKEN = SUPPORTED_TOKENS[0]; +export const DEFAULT_TOKEN: TokenConfig = (() => { + const first = SUPPORTED_TOKENS[0]; + if (!first) { + throw new Error(`No curated token is configured for ${NETWORK}.`); + } + return first; +})(); /** * Curated lookup by exact contract id, e.g. "SM3VDX...sbtc-token". diff --git a/frontend/src/lib/openclaw-server.ts b/frontend/src/lib/openclaw-server.ts index ff454e8..feb7601 100644 --- a/frontend/src/lib/openclaw-server.ts +++ b/frontend/src/lib/openclaw-server.ts @@ -3,6 +3,7 @@ * as Next.js route handlers on the same Vercel deployment (no separate host). * Response shapes are kept identical to the standalone Express service. */ +import { NETWORK, CONTRACT_DEPLOYER } from "./constants"; import { fetchCallReadOnlyFunction, cvToJSON, @@ -15,15 +16,10 @@ import { // Config // ============================================================================ -const NETWORK = (process.env.NEXT_PUBLIC_NETWORK ?? "mainnet") as - | "testnet" - | "mainnet"; +// Network and deployer come from the one validated source the UI uses, so the +// API can never read a different chain from the app serving it. const IS_MAINNET = NETWORK === "mainnet"; -const CONTRACT_DEPLOYER = - process.env.NEXT_PUBLIC_CONTRACT_DEPLOYER ?? - "SP2V6TCRFTYQHP8F4D9HSFZHRQNGVBQEZR0TMSM79"; - const STREAM_MANAGER_CONTRACT = `${CONTRACT_DEPLOYER}.stream-manager`; const STREAM_FACTORY_CONTRACT = `${CONTRACT_DEPLOYER}.stream-factory`; diff --git a/tests/network-config.test.ts b/tests/network-config.test.ts new file mode 100644 index 0000000..f11cec8 --- /dev/null +++ b/tests/network-config.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; + +// constants.ts validates the network and deployer when it is first imported, so +// each case re-imports it fresh under its own environment. +const saved = { + network: process.env.NEXT_PUBLIC_NETWORK, + deployer: process.env.NEXT_PUBLIC_CONTRACT_DEPLOYER, +}; + +function setEnv(network?: string, deployer?: string) { + if (network === undefined) delete process.env.NEXT_PUBLIC_NETWORK; + else process.env.NEXT_PUBLIC_NETWORK = network; + if (deployer === undefined) delete process.env.NEXT_PUBLIC_CONTRACT_DEPLOYER; + else process.env.NEXT_PUBLIC_CONTRACT_DEPLOYER = deployer; +} + +async function loadConstants(network?: string, deployer?: string) { + vi.resetModules(); + setEnv(network, deployer); + return import("../frontend/src/lib/constants"); +} + +afterEach(() => { + setEnv(saved.network, saved.deployer); + vi.resetModules(); +}); + +const TESTNET_DEPLOYER = "ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM"; + +describe("network configuration", () => { + it("defaults to the live mainnet deployment when nothing is set", async () => { + // What `npm run dev` gets with no env file. It used to default to testnet + // with a mainnet deployer, which left the token list empty and crashed. + const c = await loadConstants(); + expect(c.NETWORK).toBe("mainnet"); + expect(c.CONTRACT_DEPLOYER).toBe("SP2V6TCRFTYQHP8F4D9HSFZHRQNGVBQEZR0TMSM79"); + expect(c.DEFAULT_TOKEN.symbol).toBe("sBTC"); + }); + + it("builds for testnet when both variables are set as a pair", async () => { + const c = await loadConstants("testnet", TESTNET_DEPLOYER); + expect(c.NETWORK).toBe("testnet"); + expect(c.DEFAULT_TOKEN.contractId).toBe(`${TESTNET_DEPLOYER}.mock-sip010-token`); + }); + + it("stops at startup on a testnet network with a mainnet deployer", async () => { + await expect(loadConstants("testnet")).rejects.toThrow(/NEXT_PUBLIC_CONTRACT_DEPLOYER/); + }); + + it("stops at startup on a mainnet network with a testnet deployer", async () => { + await expect(loadConstants("mainnet", TESTNET_DEPLOYER)).rejects.toThrow(/mainnet/); + }); + + it("rejects a network name it does not know", async () => { + await expect(loadConstants("devnet")).rejects.toThrow(/NEXT_PUBLIC_NETWORK/); + }); +}); diff --git a/tests/token-metadata-client.test.ts b/tests/token-metadata-client.test.ts index d6a5367..94598ab 100644 --- a/tests/token-metadata-client.test.ts +++ b/tests/token-metadata-client.test.ts @@ -1,8 +1,26 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from "vitest"; // The chain is mocked: these tests are about the cache policy around the reads, -// not the reads themselves. `NEXT_PUBLIC_NETWORK` is unset here, so the module -// runs as a testnet build and every contract id below is a testnet one. +// not the reads themselves. The module runs as a testnet build, so every +// contract id below is a testnet one. Set before any import evaluates, and +// restored after, so other test files still see the default (mainnet). +const savedEnv = vi.hoisted(() => { + const saved = { + network: process.env.NEXT_PUBLIC_NETWORK, + deployer: process.env.NEXT_PUBLIC_CONTRACT_DEPLOYER, + }; + process.env.NEXT_PUBLIC_NETWORK = "testnet"; + process.env.NEXT_PUBLIC_CONTRACT_DEPLOYER = "ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM"; + return saved; +}); + +afterAll(() => { + if (savedEnv.network === undefined) delete process.env.NEXT_PUBLIC_NETWORK; + else process.env.NEXT_PUBLIC_NETWORK = savedEnv.network; + if (savedEnv.deployer === undefined) delete process.env.NEXT_PUBLIC_CONTRACT_DEPLOYER; + else process.env.NEXT_PUBLIC_CONTRACT_DEPLOYER = savedEnv.deployer; +}); + const readOnly = vi.fn(); // Mocked by path: the frontend resolves its own copy of the package, which is a // different module from the one at the repo root.