diff --git a/.env.example b/.env.example index a7474769..6f02ad2a 100644 --- a/.env.example +++ b/.env.example @@ -14,4 +14,16 @@ NEXT_PUBLIC_NETWORK2_RPC_URL= NEXT_PUBLIC_NETWORK2_INDEXER_URL= NEXT_PUBLIC_NETWORK2_GNO_WEB_URL= +# Tertiary Network +NEXT_PUBLIC_NETWORK3_NAME= +NEXT_PUBLIC_NETWORK3_CHAIN_ID= +NEXT_PUBLIC_NETWORK3_API_URL= +NEXT_PUBLIC_NETWORK3_RPC_URL= +NEXT_PUBLIC_NETWORK3_INDEXER_URL= +NEXT_PUBLIC_NETWORK3_GNO_WEB_URL= + +# Selection / order overrides (by chainId) +NEXT_PUBLIC_DEFAULT_CHAIN_ID= +NEXT_PUBLIC_NETWORK_ORDER= + NEXT_PUBLIC_GA_TRACKING_ID= \ No newline at end of file diff --git a/src/common/clients/node-client/utility.ts b/src/common/clients/node-client/utility.ts index c58c9530..25b3317c 100644 --- a/src/common/clients/node-client/utility.ts +++ b/src/common/clients/node-client/utility.ts @@ -10,8 +10,9 @@ export const makeRPCUrl = (url: string) => { }; } + const httpTarget = `http://${uri}`; return { - httpUrl: `http://${uri}`, + httpUrl: `/api/rpc-proxy?target=${encodeURIComponent(httpTarget)}`, wsUrl: `ws://${uri}`, }; }; diff --git a/src/common/config/network.config.test.ts b/src/common/config/network.config.test.ts index f8499be7..66e65639 100644 --- a/src/common/config/network.config.test.ts +++ b/src/common/config/network.config.test.ts @@ -1,4 +1,4 @@ -import { getNetworksFromEnv, getNetworkConfig } from "./network.config"; +import { getNetworksFromEnv, getNetworkConfig, applyNetworkOrder, getDefaultChain } from "./network.config"; import { ChainModel } from "@/models/chain-model"; const originalEnv = { ...process.env }; @@ -7,6 +7,26 @@ describe("Network configuration", () => { beforeEach(() => { jest.resetModules(); process.env = { ...originalEnv }; + delete process.env.NEXT_PUBLIC_NETWORK1_NAME; + delete process.env.NEXT_PUBLIC_NETWORK1_CHAIN_ID; + delete process.env.NEXT_PUBLIC_NETWORK1_API_URL; + delete process.env.NEXT_PUBLIC_NETWORK1_RPC_URL; + delete process.env.NEXT_PUBLIC_NETWORK1_INDEXER_URL; + delete process.env.NEXT_PUBLIC_NETWORK1_GNO_WEB_URL; + delete process.env.NEXT_PUBLIC_NETWORK2_NAME; + delete process.env.NEXT_PUBLIC_NETWORK2_CHAIN_ID; + delete process.env.NEXT_PUBLIC_NETWORK2_API_URL; + delete process.env.NEXT_PUBLIC_NETWORK2_RPC_URL; + delete process.env.NEXT_PUBLIC_NETWORK2_INDEXER_URL; + delete process.env.NEXT_PUBLIC_NETWORK2_GNO_WEB_URL; + delete process.env.NEXT_PUBLIC_NETWORK3_NAME; + delete process.env.NEXT_PUBLIC_NETWORK3_CHAIN_ID; + delete process.env.NEXT_PUBLIC_NETWORK3_API_URL; + delete process.env.NEXT_PUBLIC_NETWORK3_RPC_URL; + delete process.env.NEXT_PUBLIC_NETWORK3_INDEXER_URL; + delete process.env.NEXT_PUBLIC_NETWORK3_GNO_WEB_URL; + delete process.env.NEXT_PUBLIC_DEFAULT_CHAIN_ID; + delete process.env.NEXT_PUBLIC_NETWORK_ORDER; }); afterEach(() => { @@ -83,6 +103,59 @@ describe("Network configuration", () => { expect(networks[1]).toEqual(undefined); }); + it("should return all three networks when network 1, 2, and 3 environment variables are set", () => { + process.env.NEXT_PUBLIC_NETWORK1_NAME = "Test Network 1"; + process.env.NEXT_PUBLIC_NETWORK1_CHAIN_ID = "test-chain-1"; + process.env.NEXT_PUBLIC_NETWORK1_API_URL = "https://api.test1.com"; + process.env.NEXT_PUBLIC_NETWORK1_RPC_URL = "https://rpc.test1.com"; + process.env.NEXT_PUBLIC_NETWORK1_INDEXER_URL = "https://indexer.test1.com"; + process.env.NEXT_PUBLIC_NETWORK1_GNO_WEB_URL = "https://gno.test1.com"; + + process.env.NEXT_PUBLIC_NETWORK2_NAME = "Test Network 2"; + process.env.NEXT_PUBLIC_NETWORK2_CHAIN_ID = "test-chain-2"; + process.env.NEXT_PUBLIC_NETWORK2_API_URL = "https://api.test2.com"; + process.env.NEXT_PUBLIC_NETWORK2_RPC_URL = "https://rpc.test2.com"; + process.env.NEXT_PUBLIC_NETWORK2_INDEXER_URL = "https://indexer.test2.com"; + process.env.NEXT_PUBLIC_NETWORK2_GNO_WEB_URL = "https://gno.test2.com"; + + process.env.NEXT_PUBLIC_NETWORK3_NAME = "Test Network 3"; + process.env.NEXT_PUBLIC_NETWORK3_CHAIN_ID = "test-chain-3"; + process.env.NEXT_PUBLIC_NETWORK3_API_URL = "https://api.test3.com"; + process.env.NEXT_PUBLIC_NETWORK3_RPC_URL = "https://rpc.test3.com"; + process.env.NEXT_PUBLIC_NETWORK3_INDEXER_URL = "https://indexer.test3.com"; + process.env.NEXT_PUBLIC_NETWORK3_GNO_WEB_URL = "https://gno.test3.com"; + + const networks = getNetworksFromEnv(); + + expect(networks.length).toBe(3); + expect(networks[2]).toEqual({ + name: "Test Network 3", + chainId: "test-chain-3", + apiUrl: "https://api.test3.com", + rpcUrl: "https://rpc.test3.com", + indexerUrl: "https://indexer.test3.com", + gnoWebUrl: "https://gno.test3.com", + }); + }); + + it("should not include NETWORK3 if its required environment variables are missing", () => { + process.env.NEXT_PUBLIC_NETWORK1_NAME = "Test Network 1"; + process.env.NEXT_PUBLIC_NETWORK1_CHAIN_ID = "test-chain-1"; + process.env.NEXT_PUBLIC_NETWORK1_API_URL = "https://api.test1.com"; + process.env.NEXT_PUBLIC_NETWORK1_RPC_URL = "https://rpc.test1.com"; + process.env.NEXT_PUBLIC_NETWORK1_INDEXER_URL = "https://indexer.test1.com"; + + // NETWORK3 partially set: missing NAME and CHAIN_ID + process.env.NEXT_PUBLIC_NETWORK3_API_URL = "https://api.test3.com"; + process.env.NEXT_PUBLIC_NETWORK3_RPC_URL = "https://rpc.test3.com"; + process.env.NEXT_PUBLIC_NETWORK3_INDEXER_URL = "https://indexer.test3.com"; + + const networks = getNetworksFromEnv(); + + expect(networks.length).toBe(1); + expect(networks[0].chainId).toBe("test-chain-1"); + }); + it("should return environment networks when available", () => { process.env.NEXT_PUBLIC_NETWORK1_NAME = "Test Network 1"; process.env.NEXT_PUBLIC_NETWORK1_CHAIN_ID = "test-chain-1"; @@ -137,4 +210,67 @@ describe("Network configuration", () => { expect(networks[1].name).toBe("Secondary Network"); }); }); + + describe("applyNetworkOrder", () => { + const sampleChains: ChainModel[] = [ + { name: "A", chainId: "a", apiUrl: null, rpcUrl: null, indexerUrl: null, gnoWebUrl: null }, + { name: "B", chainId: "b", apiUrl: null, rpcUrl: null, indexerUrl: null, gnoWebUrl: null }, + { name: "C", chainId: "c", apiUrl: null, rpcUrl: null, indexerUrl: null, gnoWebUrl: null }, + ]; + + it("should return chains unchanged when NEXT_PUBLIC_NETWORK_ORDER is unset", () => { + const result = applyNetworkOrder(sampleChains); + expect(result.map(c => c.chainId)).toEqual(["a", "b", "c"]); + }); + + it("should reorder chains according to NEXT_PUBLIC_NETWORK_ORDER", () => { + process.env.NEXT_PUBLIC_NETWORK_ORDER = "c,a,b"; + const result = applyNetworkOrder(sampleChains); + expect(result.map(c => c.chainId)).toEqual(["c", "a", "b"]); + }); + + it("should append chains missing from order at the end in original order", () => { + process.env.NEXT_PUBLIC_NETWORK_ORDER = "c"; + const result = applyNetworkOrder(sampleChains); + expect(result.map(c => c.chainId)).toEqual(["c", "a", "b"]); + }); + + it("should ignore unknown chainIds in NEXT_PUBLIC_NETWORK_ORDER", () => { + process.env.NEXT_PUBLIC_NETWORK_ORDER = "unknown, b , c"; + const result = applyNetworkOrder(sampleChains); + expect(result.map(c => c.chainId)).toEqual(["b", "c", "a"]); + }); + + it("should treat empty or whitespace-only order as unset", () => { + process.env.NEXT_PUBLIC_NETWORK_ORDER = " , "; + const result = applyNetworkOrder(sampleChains); + expect(result.map(c => c.chainId)).toEqual(["a", "b", "c"]); + }); + }); + + describe("getDefaultChain", () => { + const sampleChains: ChainModel[] = [ + { name: "A", chainId: "a", apiUrl: null, rpcUrl: null, indexerUrl: null, gnoWebUrl: null }, + { name: "B", chainId: "b", apiUrl: null, rpcUrl: null, indexerUrl: null, gnoWebUrl: null }, + ]; + + it("should return the first chain when NEXT_PUBLIC_DEFAULT_CHAIN_ID is unset", () => { + expect(getDefaultChain(sampleChains)?.chainId).toBe("a"); + }); + + it("should return the matching chain when NEXT_PUBLIC_DEFAULT_CHAIN_ID matches", () => { + process.env.NEXT_PUBLIC_DEFAULT_CHAIN_ID = "b"; + expect(getDefaultChain(sampleChains)?.chainId).toBe("b"); + }); + + it("should fall back to the first chain when NEXT_PUBLIC_DEFAULT_CHAIN_ID does not match", () => { + process.env.NEXT_PUBLIC_DEFAULT_CHAIN_ID = "missing"; + expect(getDefaultChain(sampleChains)?.chainId).toBe("a"); + }); + + it("should return undefined when chains array is empty", () => { + process.env.NEXT_PUBLIC_DEFAULT_CHAIN_ID = "a"; + expect(getDefaultChain([])).toBeUndefined(); + }); + }); }); diff --git a/src/common/config/network.config.ts b/src/common/config/network.config.ts index 44b6d48e..29276b14 100644 --- a/src/common/config/network.config.ts +++ b/src/common/config/network.config.ts @@ -37,17 +37,67 @@ export function getNetworksFromEnv(): ChainModel[] { }); } + if ( + process.env.NEXT_PUBLIC_NETWORK3_NAME && + process.env.NEXT_PUBLIC_NETWORK3_CHAIN_ID && + process.env.NEXT_PUBLIC_NETWORK3_API_URL && + process.env.NEXT_PUBLIC_NETWORK3_RPC_URL && + process.env.NEXT_PUBLIC_NETWORK3_INDEXER_URL + ) { + networks.push({ + name: process.env.NEXT_PUBLIC_NETWORK3_NAME, + chainId: process.env.NEXT_PUBLIC_NETWORK3_CHAIN_ID, + apiUrl: process.env.NEXT_PUBLIC_NETWORK3_API_URL || null, + rpcUrl: process.env.NEXT_PUBLIC_NETWORK3_RPC_URL || null, + indexerUrl: process.env.NEXT_PUBLIC_NETWORK3_INDEXER_URL || null, + gnoWebUrl: process.env.NEXT_PUBLIC_NETWORK3_GNO_WEB_URL || null, + }); + } + return networks; } -export function getNetworkConfig(defaultChains: ChainModel[]): ChainModel[] { - const envNetworks = getNetworksFromEnv(); +export function applyNetworkOrder(chains: ChainModel[]): ChainModel[] { + const orderValue = process.env.NEXT_PUBLIC_NETWORK_ORDER; + if (!orderValue) return chains; + + const orderIds = orderValue + .split(",") + .map(s => s.trim()) + .filter(Boolean); + if (orderIds.length === 0) return chains; - if (envNetworks.length > 0) { - return envNetworks; + const byId = new Map(chains.map(c => [c.chainId, c])); + const used = new Set(); + const ordered: ChainModel[] = []; + + for (const id of orderIds) { + const chain = byId.get(id); + if (chain && !used.has(id)) { + ordered.push(chain); + used.add(id); + } + } + for (const chain of chains) { + if (!used.has(chain.chainId)) ordered.push(chain); + } + return ordered; +} + +export function getDefaultChain(chains: ChainModel[]): ChainModel | undefined { + if (chains.length === 0) return undefined; + const defaultId = process.env.NEXT_PUBLIC_DEFAULT_CHAIN_ID; + if (defaultId) { + const match = chains.find(c => c.chainId === defaultId); + if (match) return match; } + return chains[0]; +} - return defaultChains; +export function getNetworkConfig(defaultChains: ChainModel[]): ChainModel[] { + const envNetworks = getNetworksFromEnv(); + const chains = envNetworks.length > 0 ? envNetworks : defaultChains; + return applyNetworkOrder(chains); } export function getNetworkByChainId(chainId: string, networks: ChainModel[]): ChainModel | undefined { diff --git a/src/components/ui/network/network.tsx b/src/components/ui/network/network.tsx index a086e256..01dce2f1 100644 --- a/src/components/ui/network/network.tsx +++ b/src/components/ui/network/network.tsx @@ -154,7 +154,7 @@ const Network = ({ entry, chains, toggle, toggleHandler, networkSettingHandler, {chain.name} - + {chain.rpcUrl} @@ -224,6 +224,9 @@ const NetworkButton = styled.button` .svg-icon { width: 24px; height: 24px; + min-width: 24px; + min-height: 24px; + flex-shrink: 0; } `; @@ -287,6 +290,8 @@ const NetworkList = styled.ul` display: flex; flex-direction: column; width: 100%; + min-width: 0; + overflow: hidden; gap: 6px; } diff --git a/src/pages/api/rpc-proxy.ts b/src/pages/api/rpc-proxy.ts new file mode 100644 index 00000000..ed22961e --- /dev/null +++ b/src/pages/api/rpc-proxy.ts @@ -0,0 +1,26 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const target = req.query.target as string; + + if (!target) { + return res.status(400).json({ error: "Missing target parameter" }); + } + + if (!target.startsWith("http://")) { + return res.status(400).json({ error: "Proxy is only for HTTP endpoints" }); + } + + try { + const response = await fetch(target, { + method: req.method || "POST", + headers: { "Content-Type": "application/json" }, + body: req.method !== "GET" ? JSON.stringify(req.body) : undefined, + }); + + const data = await response.json(); + return res.status(response.status).json(data); + } catch (error) { + return res.status(502).json({ error: "Failed to reach target endpoint" }); + } +} diff --git a/src/providers/network-provider.tsx b/src/providers/network-provider.tsx index 7b44d10a..9ca48009 100644 --- a/src/providers/network-provider.tsx +++ b/src/providers/network-provider.tsx @@ -12,6 +12,7 @@ import { HttpRPCClient } from "@/common/clients/rpc-client/http-rpc-client"; import { ChainModel, getChainSupportType } from "@/models/chain-model"; import { NetworkState } from "@/states"; +import { getDefaultChain } from "@/common/config/network.config"; interface NetworkContextProps { chains: ChainModel[]; @@ -72,8 +73,8 @@ const NetworkProvider: React.FC> = // Find the chain matching the requested chainId const chain = requestedChainId ? chains.find(chain => chain.chainId === requestedChainId) : null; - // If no valid chain exists, use the default chain (first one) - const selectedChain = chain || chains[0]; + // If no valid chain exists, use the configured default (NEXT_PUBLIC_DEFAULT_CHAIN_ID) or fall back to chains[0] + const selectedChain = chain || getDefaultChain(chains) || chains[0]; // Warning log when an invalid chainId is entered if (requestedChainId && !chain) {