From a47afa973405b71e5cac4395afe284c001882b8a Mon Sep 17 00:00:00 2001 From: adenueltech Date: Sat, 17 Oct 2026 21:06:32 +0100 Subject: [PATCH] feat: add wallet network selection for testnet, mainnet and custom (#889) - Add src/lib/networks.ts: presets, custom networks, stored preference, validation, live RPC passphrase check and explorer URL helpers - Resolve RPC, Horizon, passphrase, contract ID and USDC address from the active network in config/stellar/freighter and the components that hard-coded them, so endpoints are correct per network - Add NetworkSelector: always-visible badge in the navbar plus a dialog to switch; Mainnet needs explicit confirmation, custom networks must pass a getNetwork check, unconfigured presets are disabled - Warn when the Freighter wallet is on a different network than the app - Remove the static badge from NetworkStatus (now only RPC health) - Document optional per-network env vars in .env.example Closes #889 Co-Authored-By: Claude Opus 5.5 (1M context) --- .env.example | 13 + src/__tests__/networks.test.ts | 133 +++++++++ src/app/dev/debugger/page.tsx | 3 +- src/app/dev/inspect/[id]/page.tsx | 5 +- src/app/pay/[invoiceId]/[amount]/page.tsx | 5 +- src/app/pay/batch/page.tsx | 5 +- src/app/payments/page.tsx | 3 +- src/app/settings/verify/page.tsx | 5 +- src/components/AchievementCard.tsx | 5 +- src/components/InvoiceTimeline.tsx | 5 +- src/components/Navbar.tsx | 3 + src/components/NetworkSelector.tsx | 336 ++++++++++++++++++++++ src/components/NetworkStatus.tsx | 80 +----- src/components/PayModal.tsx | 5 +- src/components/ReceiptPDF.tsx | 5 +- src/components/SuccessAnimation.tsx | 5 +- src/components/TxConfirmModal.tsx | 5 +- src/components/wallet/WalletMenu.tsx | 6 +- src/hooks/useInvoiceStream.ts | 5 +- src/hooks/useStellarTransaction.ts | 7 +- src/lib/adminRotation.ts | 5 +- src/lib/confidential.ts | 5 +- src/lib/config.ts | 22 +- src/lib/freighter.ts | 8 +- src/lib/networks.ts | 217 ++++++++++++++ src/lib/paymentNonce.ts | 5 +- src/lib/stellar.ts | 25 +- src/lib/txImport.ts | 3 +- 28 files changed, 784 insertions(+), 145 deletions(-) create mode 100644 src/__tests__/networks.test.ts create mode 100644 src/components/NetworkSelector.tsx create mode 100644 src/lib/networks.ts diff --git a/.env.example b/.env.example index d0a5c51d..2f7a08dc 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,19 @@ NEXT_PUBLIC_STELLAR_NETWORK=testnet NEXT_PUBLIC_CONTRACT_ID=YOUR_CONTRACT_ID_HERE NEXT_PUBLIC_RPC_URL=https://soroban-testnet.stellar.org + +# Optional: per-network settings for the in-app network selector (#889). +# The un-prefixed vars above apply to NEXT_PUBLIC_STELLAR_NETWORK only; set +# these to let users switch to the other network. A network with no contract +# ID is shown as "not configured" and cannot be selected. +# NEXT_PUBLIC_TESTNET_CONTRACT_ID= +# NEXT_PUBLIC_TESTNET_RPC_URL= +# NEXT_PUBLIC_TESTNET_HORIZON_URL= +# NEXT_PUBLIC_TESTNET_USDC_ADDRESS= +# NEXT_PUBLIC_MAINNET_CONTRACT_ID= +# NEXT_PUBLIC_MAINNET_RPC_URL= +# NEXT_PUBLIC_MAINNET_HORIZON_URL= +# NEXT_PUBLIC_MAINNET_USDC_ADDRESS= API_KEY_SIGNING_SECRET=replace-with-a-long-random-server-secret CSRF_SECRET=replace-with-a-long-random-server-secret diff --git a/src/__tests__/networks.test.ts b/src/__tests__/networks.test.ts new file mode 100644 index 00000000..69e94f60 --- /dev/null +++ b/src/__tests__/networks.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { StrKey } from "@stellar/stellar-sdk"; +import { + DEFAULT_NETWORK_ID, + MAINNET_PASSPHRASE, + NETWORK_STORAGE_KEY, + TESTNET_PASSPHRASE, + buildCustomNetwork, + checkRpcNetwork, + explorerAccountUrl, + explorerTxUrl, + getActiveNetwork, + getPresetNetwork, + readNetworkPreference, + resolveNetwork, + validateCustomNetwork, + writeNetworkPreference, + type CustomNetworkInput, +} from "@/lib/networks"; + +const CONTRACT = StrKey.encodeContract(Buffer.alloc(32, 7)); + +const goodCustom: CustomNetworkInput = { + label: "Local", + passphrase: "Standalone Network ; February 2017", + rpcUrl: "http://localhost:8000/soroban/rpc", + horizonUrl: "http://localhost:8000", + contractId: CONTRACT, +}; + +function jsonResponse(body: unknown, status = 200) { + return { ok: status >= 200 && status < 300, status, json: async () => body } as Response; +} + +describe("network selection (#889)", () => { + beforeEach(() => localStorage.clear()); + + it("presets use the right passphrase and explorer per network", () => { + const testnet = getPresetNetwork("testnet"); + const mainnet = getPresetNetwork("mainnet"); + expect(testnet.passphrase).toBe(TESTNET_PASSPHRASE); + expect(mainnet.passphrase).toBe(MAINNET_PASSPHRASE); + expect(testnet.explorerTxBase).toContain("/testnet/"); + expect(mainnet.explorerTxBase).toContain("/public/"); + expect(testnet.horizonUrl).not.toBe(mainnet.horizonUrl); + }); + + it("uses the build default when nothing is stored", () => { + expect(readNetworkPreference()).toBeNull(); + expect(getActiveNetwork().id).toBe(DEFAULT_NETWORK_ID); + }); + + it("remembers the chosen network across reads", () => { + writeNetworkPreference({ id: "mainnet" }); + expect(readNetworkPreference()).toEqual({ id: "mainnet" }); + expect(getActiveNetwork().passphrase).toBe(MAINNET_PASSPHRASE); + + writeNetworkPreference(null); + expect(localStorage.getItem(NETWORK_STORAGE_KEY)).toBeNull(); + }); + + it("ignores corrupt or unknown stored values", () => { + localStorage.setItem(NETWORK_STORAGE_KEY, "{not json"); + expect(getActiveNetwork().id).toBe(DEFAULT_NETWORK_ID); + localStorage.setItem(NETWORK_STORAGE_KEY, JSON.stringify({ id: "futurenet" })); + expect(getActiveNetwork().id).toBe(DEFAULT_NETWORK_ID); + }); + + it("resolves a valid custom network and falls back when it is invalid", () => { + const custom = resolveNetwork({ id: "custom", custom: goodCustom }); + expect(custom.id).toBe("custom"); + expect(custom.rpcUrl).toBe(goodCustom.rpcUrl); + expect(custom.contractId).toBe(CONTRACT); + + const broken = resolveNetwork({ id: "custom", custom: { ...goodCustom, rpcUrl: "not a url" } }); + expect(broken.id).toBe(DEFAULT_NETWORK_ID); + }); + + it("never reuses the default network's contract ID on the other network", () => { + const other = DEFAULT_NETWORK_ID === "testnet" ? "mainnet" : "testnet"; + const shared = process.env.NEXT_PUBLIC_CONTRACT_ID; + if (shared) expect(getPresetNetwork(other).contractId).not.toBe(shared); + }); + + describe("validateCustomNetwork", () => { + it("accepts a well-formed network", () => { + expect(validateCustomNetwork(goodCustom)).toEqual({}); + }); + + it("rejects plain http for remote hosts but allows localhost", () => { + const errors = validateCustomNetwork({ ...goodCustom, rpcUrl: "http://rpc.example.org" }); + expect(errors.rpcUrl).toBeDefined(); + expect(validateCustomNetwork({ ...goodCustom, rpcUrl: "http://127.0.0.1:8000" }).rpcUrl).toBeUndefined(); + }); + + it("requires a passphrase and valid contract addresses", () => { + const errors = validateCustomNetwork({ ...goodCustom, passphrase: " ", contractId: "Cbad", usdcAddress: "nope" }); + expect(errors.passphrase).toBeDefined(); + expect(errors.contractId).toBeDefined(); + expect(errors.usdcAddress).toBeDefined(); + }); + }); + + describe("checkRpcNetwork", () => { + it("passes when the RPC reports the expected passphrase", async () => { + const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ result: { passphrase: goodCustom.passphrase } })); + await expect(checkRpcNetwork(goodCustom.rpcUrl, goodCustom.passphrase, fetchImpl)).resolves.toEqual({ ok: true }); + const body = JSON.parse(fetchImpl.mock.calls[0][1].body); + expect(body.method).toBe("getNetwork"); + }); + + it("fails when the RPC is on a different network", async () => { + const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ result: { passphrase: TESTNET_PASSPHRASE } })); + const result = await checkRpcNetwork(goodCustom.rpcUrl, MAINNET_PASSPHRASE, fetchImpl); + expect(result.ok).toBe(false); + }); + + it("fails on HTTP errors and unreachable hosts", async () => { + expect((await checkRpcNetwork("https://x", "p", vi.fn().mockResolvedValue(jsonResponse({}, 503)))).ok).toBe(false); + expect((await checkRpcNetwork("https://x", "p", vi.fn().mockRejectedValue(new Error("boom"))))).toEqual({ + ok: false, + error: "Could not reach RPC: boom", + }); + }); + }); + + it("builds explorer links for the given network", () => { + const mainnet = getPresetNetwork("mainnet"); + expect(explorerTxUrl("abc", mainnet)).toBe("https://stellar.expert/explorer/public/tx/abc"); + expect(explorerAccountUrl("GABC", mainnet)).toBe("https://stellar.expert/explorer/public/account/GABC"); + expect(buildCustomNetwork({ ...goodCustom, passphrase: MAINNET_PASSPHRASE }).explorerTxBase).toContain("/public/"); + }); +}); diff --git a/src/app/dev/debugger/page.tsx b/src/app/dev/debugger/page.tsx index 21ccb4a9..e56f89fc 100644 --- a/src/app/dev/debugger/page.tsx +++ b/src/app/dev/debugger/page.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { splitClient } from "@/lib/stellar"; import ThemeToggle from "@/components/ThemeToggle"; +import { getActiveNetwork } from "@/lib/networks"; const DEV_MODE = process.env.NEXT_PUBLIC_DEV_MODE === "true"; @@ -61,7 +62,7 @@ function DebuggerContent() { } else { // Generic fallback: build a minimal transaction envelope and simulate it const { rpc } = await import("@stellar/stellar-sdk"); - const rpcUrl = process.env.NEXT_PUBLIC_RPC_URL ?? "https://soroban-testnet.stellar.org"; + const rpcUrl = getActiveNetwork().rpcUrl; const server = new rpc.Server(rpcUrl); // as any: simulateTransaction overload with plain object args is not in @stellar/stellar-sdk RPC types const raw = await (server as any).simulateTransaction({ diff --git a/src/app/dev/inspect/[id]/page.tsx b/src/app/dev/inspect/[id]/page.tsx index bd4cd210..6ae7ebc0 100644 --- a/src/app/dev/inspect/[id]/page.tsx +++ b/src/app/dev/inspect/[id]/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useState, useCallback } from "react"; import { useParams } from "next/navigation"; import { splitClient } from "@/lib/stellar"; import type { Invoice } from "@stellar-split/sdk"; +import { getActiveNetwork } from "@/lib/networks"; const DEV_MODE = process.env.NEXT_PUBLIC_DEV_MODE === "true"; @@ -104,8 +105,8 @@ export default function InspectPage() { setError(null); try { const invoice = await splitClient.getInvoice(id); - const rpcUrl = process.env.NEXT_PUBLIC_RPC_URL ?? "https://soroban-testnet.stellar.org"; - const contractId = process.env.NEXT_PUBLIC_CONTRACT_ID ?? ""; + const rpcUrl = getActiveNetwork().rpcUrl; + const contractId = getActiveNetwork().contractId; const wasmHash = await fetchWasmHash(contractId, rpcUrl); const storageKey = `Invoice:${id}`; setData({ invoice, wasmHash, storageKey }); diff --git a/src/app/pay/[invoiceId]/[amount]/page.tsx b/src/app/pay/[invoiceId]/[amount]/page.tsx index 1df0b971..dace6fc7 100644 --- a/src/app/pay/[invoiceId]/[amount]/page.tsx +++ b/src/app/pay/[invoiceId]/[amount]/page.tsx @@ -6,15 +6,14 @@ import { splitClient, payWithNonce } from "@/lib/stellar"; import { getFreighterPublicKey } from "@/lib/freighter"; import { formatAmount, parseAmount } from "@stellar-split/sdk"; import type { Invoice } from "@stellar-split/sdk"; +import { getActiveNetwork } from "@/lib/networks"; interface Props { params: { invoiceId: string; amount: string }; } const STELLAR_EXPERT_BASE = - process.env.NEXT_PUBLIC_STELLAR_NETWORK === "mainnet" - ? "https://stellar.expert/explorer/public/tx" - : "https://stellar.expert/explorer/testnet/tx"; + getActiveNetwork().explorerTxBase; export default function DeepLinkPayPage({ params }: Props) { const { invoiceId, amount: rawAmount } = params; diff --git a/src/app/pay/batch/page.tsx b/src/app/pay/batch/page.tsx index 7087f712..a7f6c100 100644 --- a/src/app/pay/batch/page.tsx +++ b/src/app/pay/batch/page.tsx @@ -6,6 +6,7 @@ import { getFreighterPublicKey } from "@/lib/freighter"; import { parseAmount } from "@stellar-split/sdk"; import BatchPayQueue from "@/components/BatchPayQueue"; import type { Invoice } from "@stellar-split/sdk"; +import { getActiveNetwork } from "@/lib/networks"; interface QueueEntry { invoice: Invoice; @@ -13,9 +14,7 @@ interface QueueEntry { } const STELLAR_EXPERT_BASE = - process.env.NEXT_PUBLIC_STELLAR_NETWORK === "mainnet" - ? "https://stellar.expert/explorer/public/tx" - : "https://stellar.expert/explorer/testnet/tx"; + getActiveNetwork().explorerTxBase; export default function BatchPayPage() { const [search, setSearch] = useState(""); diff --git a/src/app/payments/page.tsx b/src/app/payments/page.tsx index a876aa39..c28ff3d0 100644 --- a/src/app/payments/page.tsx +++ b/src/app/payments/page.tsx @@ -9,8 +9,9 @@ import { formatAmount, truncateAddress } from "@stellar-split/sdk"; import type { Invoice, Payment } from "@stellar-split/sdk"; import { SkeletonPaymentRow } from "@/components/Skeleton"; import PaymentScheduler from "@/components/PaymentScheduler"; +import { getActiveNetwork } from "@/lib/networks"; -const STELLAR_EXPERT_BASE = "https://stellar.expert/explorer/testnet/tx"; +const STELLAR_EXPERT_BASE = getActiveNetwork().explorerTxBase; const ITEMS_PER_PAGE = 20; const MAX_INVOICES = 100; diff --git a/src/app/settings/verify/page.tsx b/src/app/settings/verify/page.tsx index ed59bd17..b20197c6 100644 --- a/src/app/settings/verify/page.tsx +++ b/src/app/settings/verify/page.tsx @@ -15,6 +15,7 @@ import { isValidUrl, type VerificationRequest, } from '@/lib/verificationRequests'; +import { getActiveNetwork } from "@/lib/networks"; export default function VerifyIdentityPage() { const [mounted, setMounted] = useState(false); @@ -71,9 +72,7 @@ export default function VerifyIdentityPage() { const signedMessage = await signMessage(challenge, { address, networkPassphrase: - process.env.NEXT_PUBLIC_STELLAR_NETWORK === 'mainnet' - ? 'Public Global Stellar Network ; September 2015' - : 'Test SDF Network ; September 2015', + getActiveNetwork().passphrase, }); // Store attestation diff --git a/src/components/AchievementCard.tsx b/src/components/AchievementCard.tsx index 1310452c..a676ced6 100644 --- a/src/components/AchievementCard.tsx +++ b/src/components/AchievementCard.tsx @@ -2,6 +2,7 @@ import { useRef, useState, useEffect } from "react"; import confetti from "canvas-confetti"; +import { getActiveNetwork } from "@/lib/networks"; export interface NFTDetails { tokenId?: string; @@ -19,9 +20,7 @@ interface Props { } const STELLAR_EXPERT_BASE = - process.env.NEXT_PUBLIC_STELLAR_NETWORK === "mainnet" - ? "https://stellar.expert/explorer/public/tx" - : "https://stellar.expert/explorer/testnet/tx"; + getActiveNetwork().explorerTxBase; export default function AchievementCard({ invoiceId, diff --git a/src/components/InvoiceTimeline.tsx b/src/components/InvoiceTimeline.tsx index 65d75652..aae61f5b 100644 --- a/src/components/InvoiceTimeline.tsx +++ b/src/components/InvoiceTimeline.tsx @@ -4,6 +4,7 @@ import { useEffect, useState, useCallback, useRef } from 'react'; import WalletAddress from './WalletAddress'; import RelativeTime from '@/components/ui/RelativeTime'; import { splitClient as _splitClient } from '@/lib/stellar'; +import { getActiveNetwork } from "@/lib/networks"; export interface InvoiceEvent { type: @@ -27,9 +28,7 @@ interface Props { } const STELLAR_EXPERT_BASE = - process.env.NEXT_PUBLIC_STELLAR_NETWORK === 'mainnet' - ? 'https://stellar.expert/explorer/public/tx' - : 'https://stellar.expert/explorer/testnet/tx'; + getActiveNetwork().explorerTxBase; const EVENT_META: Record< InvoiceEvent['type'], diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 387d5623..ab21a828 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -8,6 +8,7 @@ import SimulationModeToggle from "@/components/SimulationModeToggle"; import NotificationCenter from "@/components/NotificationCenter"; import HeaderShortcutsButton from "@/components/HeaderShortcutsButton"; import NetworkStatus from "@/components/NetworkStatus"; +import NetworkSelector from "@/components/NetworkSelector"; import GlobalSearch from "@/components/GlobalSearch"; import MobileSidebar from "@/components/layout/MobileSidebar"; @@ -106,6 +107,8 @@ export default function Navbar() { {/* Right-side actions */}
+ {/* Network selector stays visible at every breakpoint */} +
diff --git a/src/components/NetworkSelector.tsx b/src/components/NetworkSelector.tsx new file mode 100644 index 00000000..7f757e82 --- /dev/null +++ b/src/components/NetworkSelector.tsx @@ -0,0 +1,336 @@ +"use client"; + +import { useEffect, useState } from "react"; +import FocusTrap from "@/components/FocusTrap"; +import { + DEFAULT_NETWORK_ID, + checkRpcNetwork, + describePassphrase, + getActiveNetwork, + getPresetNetwork, + readNetworkPreference, + validateCustomNetwork, + writeNetworkPreference, + MAINNET_PASSPHRASE, + type CustomNetworkInput, + type NetworkConfig, + type NetworkId, + type NetworkPreference, +} from "@/lib/networks"; + +const BADGE_STYLES: Record = { + mainnet: "bg-emerald-500/15 border-emerald-500/40 text-emerald-300", + testnet: "bg-amber-500/15 border-amber-500/40 text-amber-300", + custom: "bg-violet-500/15 border-violet-500/40 text-violet-300", +}; + +const BADGE_ICONS: Record = { mainnet: "🌐", testnet: "πŸ§ͺ", custom: "πŸ› " }; + +const EMPTY_CUSTOM: CustomNetworkInput = { + label: "", + passphrase: "", + rpcUrl: "", + horizonUrl: "", + contractId: "", + usdcAddress: "", +}; + +const CUSTOM_FIELDS: { key: keyof CustomNetworkInput; label: string; placeholder: string; required?: boolean }[] = [ + { key: "label", label: "Name", placeholder: "My local network" }, + { key: "rpcUrl", label: "Soroban RPC URL", placeholder: "https://rpc.example.org", required: true }, + { key: "horizonUrl", label: "Horizon URL", placeholder: "https://horizon.example.org", required: true }, + { key: "passphrase", label: "Network passphrase", placeholder: "Standalone Network ; February 2017", required: true }, + { key: "contractId", label: "StellarSplit contract ID", placeholder: "C..." }, + { key: "usdcAddress", label: "USDC token contract", placeholder: "C..." }, +]; + +/** Apply a network choice. A full reload rebuilds every network-bound module. */ +function applyPreference(pref: NetworkPreference | null) { + writeNetworkPreference(pref); + window.location.reload(); +} + +/** + * NetworkSelector β€” always-visible badge for the active Stellar network that + * opens a dialog to switch between Testnet, Mainnet and a custom network. + * Switching to Mainnet needs an explicit confirmation, a custom network must + * pass a live RPC check, and a warning is shown whenever the connected + * Freighter wallet is on a different network than the app. + */ +export default function NetworkSelector() { + // Render the build default on the server and first client paint, then + // swap in the user's saved choice to avoid a hydration mismatch. + const [active, setActive] = useState(() => getPresetNetwork(DEFAULT_NETWORK_ID)); + const [open, setOpen] = useState(false); + const [selected, setSelected] = useState(DEFAULT_NETWORK_ID); + const [custom, setCustom] = useState(EMPTY_CUSTOM); + const [customErrors, setCustomErrors] = useState>({}); + const [checking, setChecking] = useState(false); + const [checkError, setCheckError] = useState(null); + const [mainnetAck, setMainnetAck] = useState(false); + const [walletPassphrase, setWalletPassphrase] = useState(null); + const [mismatchDismissed, setMismatchDismissed] = useState(false); + + useEffect(() => { + setActive(getActiveNetwork()); + }, []); + + // Compare with the network Freighter is set to, if the extension is present. + useEffect(() => { + let cancelled = false; + import("@stellar/freighter-api") + .then(async ({ isConnected, getNetworkDetails }) => { + const conn = await isConnected(); + if (!conn.isConnected) return; + const details = await getNetworkDetails(); + if (!cancelled && !details.error && details.networkPassphrase) { + setWalletPassphrase(details.networkPassphrase); + } + }) + .catch(() => null); + return () => { + cancelled = true; + }; + }, []); + + const openDialog = () => { + const pref = readNetworkPreference(); + setSelected(active.id); + setCustom(pref?.custom ?? EMPTY_CUSTOM); + setCustomErrors({}); + setCheckError(null); + setMainnetAck(false); + setOpen(true); + }; + + const presets = (["testnet", "mainnet"] as const).map((id) => getPresetNetwork(id)); + const selectedPreset = presets.find((p) => p.id === selected); + const switchingToMainnet = selected === "mainnet" && active.id !== "mainnet"; + + const handleSave = async () => { + setCheckError(null); + if (selected === "custom") { + const errors = validateCustomNetwork(custom); + setCustomErrors(errors); + if (Object.keys(errors).length > 0) return; + setChecking(true); + const result = await checkRpcNetwork(custom.rpcUrl.trim(), custom.passphrase); + setChecking(false); + if (!result.ok) { + setCheckError(result.error); + return; + } + applyPreference({ id: "custom", custom }); + return; + } + if (switchingToMainnet && !mainnetAck) return; + applyPreference(selected === DEFAULT_NETWORK_ID ? null : { id: selected }); + }; + + const mismatch = walletPassphrase !== null && walletPassphrase !== active.passphrase; + const saveDisabled = + checking || + (selected === active.id && selected !== "custom") || + (selectedPreset !== undefined && !selectedPreset.contractId) || + (switchingToMainnet && !mainnetAck); + + return ( + <> + + + {mismatch && !mismatchDismissed && ( +
+ +

+ Your Freighter wallet is on {describePassphrase(walletPassphrase!)} but StellarSplit is + using {active.label}. Switch one of them so they match before signing anything. +

+ +
+ )} + + {open && ( +
setOpen(false)} + > + setOpen(false)}> +
e.stopPropagation()} + > +
+

+ Stellar network +

+ +
+ +

+ Currently using {active.label}. Your choice is saved in this + browser and the page reloads when you switch. +

+ +
+ Choose a network + {[...presets, { id: "custom" as const, label: "Custom", contractId: "x" }].map((n) => { + const unavailable = n.id !== "custom" && !n.contractId; + return ( + + ); + })} +
+ + {selected === "custom" && ( +
+ {CUSTOM_FIELDS.map((f) => ( +
+ + setCustom((c) => ({ ...c, [f.key]: e.target.value }))} + placeholder={f.placeholder} + aria-invalid={!!customErrors[f.key]} + aria-describedby={customErrors[f.key] ? `custom-${f.key}-error` : undefined} + className={`min-h-10 rounded-lg border bg-gray-800 px-3 py-2 text-sm text-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500 ${ + customErrors[f.key] ? "border-red-500" : "border-gray-700" + }`} + /> + {customErrors[f.key] && ( +

+ {customErrors[f.key]} +

+ )} +
+ ))} +

+ We check that the RPC reports the same passphrase before switching. +

+
+ )} + + {switchingToMainnet && ( + + )} + + {checkError && ( +

+ {checkError} +

+ )} + +
+ {readNetworkPreference() ? ( + + ) : ( + + )} +
+ + +
+
+
+
+
+ )} + + ); +} diff --git a/src/components/NetworkStatus.tsx b/src/components/NetworkStatus.tsx index 4d5d2aa1..efa52d5c 100644 --- a/src/components/NetworkStatus.tsx +++ b/src/components/NetworkStatus.tsx @@ -1,36 +1,16 @@ "use client"; import { useEffect, useState, useRef, useCallback } from "react"; +import { getActiveNetwork } from "@/lib/networks"; -const RPC_URL = process.env.NEXT_PUBLIC_RPC_URL ?? ""; const POLL_MS = 60_000; -/** - * Resolve the network name from the environment variable. - * Falls back to detecting testnet from the RPC URL when the env var is absent. - */ -function resolveNetworkName(): "Mainnet" | "Testnet" | "Unknown" { - const envNet = process.env.NEXT_PUBLIC_STELLAR_NETWORK?.toLowerCase(); - if (envNet === "mainnet") return "Mainnet"; - if (envNet === "testnet") return "Testnet"; - // Heuristic: if the RPC URL contains "testnet" or "futurenet" treat as Testnet - if (RPC_URL.toLowerCase().includes("testnet") || RPC_URL.toLowerCase().includes("futurenet")) { - return "Testnet"; - } - if (RPC_URL.toLowerCase().includes("mainnet")) { - return "Mainnet"; - } - return "Unknown"; -} - -const NETWORK = resolveNetworkName(); - type RpcStatus = "healthy" | "degraded" | "offline" | "checking"; -async function checkHealth(): Promise { - if (!RPC_URL) return "offline"; +async function checkHealth(rpcUrl: string): Promise { + if (!rpcUrl) return "offline"; try { - const res = await fetch(RPC_URL, { + const res = await fetch(rpcUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "getHealth", params: {} }), @@ -50,10 +30,9 @@ async function checkHealth(): Promise { /** * NetworkStatus * - * Renders two adjacent badges in the navbar: - * 1. A permanent Mainnet / Testnet network badge β€” always visible so users - * always know which Stellar network the application is connected to. - * 2. An RPC health indicator (●) showing healthy / degraded / offline status. + * Renders an RPC health indicator (●) in the navbar showing healthy / + * degraded / offline status for the active network's Soroban RPC. The + * network itself is shown and switched by `NetworkSelector`. * * When the RPC is offline a dismissible banner is also shown at the top of the * page. @@ -68,7 +47,7 @@ export default function NetworkStatus() { const timerRef = useRef | null>(null); const poll = useCallback(async () => { - const s = await checkHealth(); + const s = await checkHealth(getActiveNetwork().rpcUrl); setStatus((prev) => { if (prev === "offline" && s !== "offline") { setJustReconnected(true); @@ -153,25 +132,6 @@ export default function NetworkStatus() { }` : "Soroban RPC: checking…"; - // ── Network badge styles ───────────────────────────────────────────────────── - - const networkBadgeClasses = - NETWORK === "Mainnet" - ? "bg-emerald-500/15 border-emerald-500/40 text-emerald-300" - : NETWORK === "Testnet" - ? "bg-amber-500/15 border-amber-500/40 text-amber-300" - : "bg-gray-500/15 border-gray-500/40 text-gray-400"; - - const networkIcon = - NETWORK === "Mainnet" ? "🌐" : NETWORK === "Testnet" ? "πŸ§ͺ" : "❓"; - - const networkTooltip = - NETWORK === "Mainnet" - ? "Connected to Stellar Mainnet β€” real assets" - : NETWORK === "Testnet" - ? "Connected to Stellar Testnet β€” test assets only" - : "Network unknown β€” check NEXT_PUBLIC_STELLAR_NETWORK"; - return ( <> {/* ── Offline banner ── */} @@ -184,9 +144,7 @@ export default function NetworkStatus() { {browserOffline ? "You are offline β€” transactions may fail" : "Network issues detected β€” transactions may fail"} - {NETWORK !== "Unknown" && ( - ({NETWORK}) - )} + ({getActiveNetwork().label})