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 3368238a..7ab25a22 100644
--- a/src/app/settings/verify/page.tsx
+++ b/src/app/settings/verify/page.tsx
@@ -83,9 +83,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.
+