Skip to content
Draft
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
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
3 changes: 2 additions & 1 deletion src/common/clients/node-client/utility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
};
};
Expand Down
138 changes: 137 additions & 1 deletion src/common/config/network.config.test.ts
Original file line number Diff line number Diff line change
@@ -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 };
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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();
});
});
});
60 changes: 55 additions & 5 deletions src/common/config/network.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
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 {
Expand Down
7 changes: 6 additions & 1 deletion src/components/ui/network/network.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ const Network = ({ entry, chains, toggle, toggleHandler, networkSettingHandler,
<Text type="h7" color="primary">
{chain.name}
</Text>
<Text type="body1" color="tertiary">
<Text type="body1" color="tertiary" className="ellipsis">
{chain.rpcUrl}
</Text>
</div>
Expand Down Expand Up @@ -224,6 +224,9 @@ const NetworkButton = styled.button<StyleProps>`
.svg-icon {
width: 24px;
height: 24px;
min-width: 24px;
min-height: 24px;
flex-shrink: 0;
}
`;

Expand Down Expand Up @@ -287,6 +290,8 @@ const NetworkList = styled.ul<StyleProps>`
display: flex;
flex-direction: column;
width: 100%;
min-width: 0;
overflow: hidden;
gap: 6px;
}

Expand Down
26 changes: 26 additions & 0 deletions src/pages/api/rpc-proxy.ts
Original file line number Diff line number Diff line change
@@ -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" });
}
}
5 changes: 3 additions & 2 deletions src/providers/network-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -72,8 +73,8 @@ const NetworkProvider: React.FC<React.PropsWithChildren<NetworkProviderPros>> =
// 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) {
Expand Down