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.