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
17 changes: 17 additions & 0 deletions frontend/.env.example
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions frontend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
!.env.production
!.env.example

# vercel
.vercel
Expand Down
43 changes: 36 additions & 7 deletions frontend/src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
Expand Down Expand Up @@ -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".
Expand Down
10 changes: 3 additions & 7 deletions frontend/src/lib/openclaw-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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`;

Expand Down
57 changes: 57 additions & 0 deletions tests/network-config.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});
24 changes: 21 additions & 3 deletions tests/token-metadata-client.test.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
Loading