('account');
@@ -237,13 +240,23 @@ export default function AdminPanel() {
{/* Header */}
-
+
Security Center
+
+ {config.displayName}
+
Asset Freeze & Administrative Controls
+
{/* Tab bar */}
diff --git a/frontend/src/pages/Debugger.tsx b/frontend/src/pages/Debugger.tsx
index 9f412241..007e328b 100644
--- a/frontend/src/pages/Debugger.tsx
+++ b/frontend/src/pages/Debugger.tsx
@@ -1,9 +1,12 @@
import { useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
+import { useNetwork } from '../hooks/useNetwork';
+import NetworkSwitcher from '../components/NetworkSwitcher';
export default function Debugger() {
const { contractName } = useParams<{ contractName?: string }>();
const { t } = useTranslation();
+ const { config } = useNetwork();
return (
@@ -17,11 +20,14 @@ export default function Debugger() {
{t('debugger.subtitle')}
- {contractName && (
-
- {t('debugger.target', { contractName })}
-
- )}
+
+ {contractName && (
+
+ {t('debugger.target', { contractName })}
+
+ )}
+
+
@@ -35,12 +41,26 @@ export default function Debugger() {
v21
+
+
+ Network
+
+ {config.displayName}
+
{t('debugger.horizon')}
-
- {t('debugger.horizonOnline')}
+
+ {config.horizonUrl.replace('https://', '')}
+
+
+
+
+ RPC
+
+
+ {config.rpcUrl.replace('https://', '')}
diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx
index 5921a401..2473c334 100644
--- a/frontend/src/pages/Settings.tsx
+++ b/frontend/src/pages/Settings.tsx
@@ -1,7 +1,10 @@
import { useTranslation } from 'react-i18next';
+import NetworkSwitcher from '../components/NetworkSwitcher';
+import { useNetwork } from '../hooks/useNetwork';
export default function Settings() {
const { t, i18n } = useTranslation();
+ const { config, isTestnet } = useNetwork();
const handleChangeLanguage = (event: React.ChangeEvent) => {
void i18n.changeLanguage(event.target.value);
@@ -15,20 +18,54 @@ export default function Settings() {
-
-
-
-
{t('settings.languageDescription')}
-
+
+ {/* Language */}
+
+
+
+
{t('settings.languageDescription')}
+
+
+
+
+ {/* Network */}
+
+
+
+
+
+ {config.displayName}
+
+
+
+ Connect to Stellar Testnet for development, or Mainnet for production. Switching
+ networks will disconnect your wallet and clear all cached data.
+
+
+
+
+ Horizon: {config.horizonUrl}
+ RPC: {config.rpcUrl}
+
+
+
diff --git a/frontend/src/providers/NetworkProvider.tsx b/frontend/src/providers/NetworkProvider.tsx
new file mode 100644
index 00000000..cb9707cc
--- /dev/null
+++ b/frontend/src/providers/NetworkProvider.tsx
@@ -0,0 +1,88 @@
+import React, { useState, useEffect, useCallback } from 'react';
+import { useQueryClient } from '@tanstack/react-query';
+import { WalletNetwork } from '@creit.tech/stellar-wallets-kit';
+import {
+ NetworkContext,
+ NetworkName,
+ StellarNetworkConfig,
+ ContractRegistry,
+} from '../hooks/useNetwork';
+import { fetchContractRegistry } from '../services/contractRegistry';
+
+const STORAGE_KEY = 'payd-network';
+
+const NETWORK_CONFIGS: Record
= {
+ testnet: {
+ name: 'testnet',
+ displayName: 'Testnet',
+ networkPassphrase: 'Test SDF Network ; September 2015',
+ horizonUrl:
+ (import.meta.env.VITE_TESTNET_HORIZON_URL as string | undefined) ||
+ 'https://horizon-testnet.stellar.org',
+ rpcUrl:
+ (import.meta.env.VITE_TESTNET_RPC_URL as string | undefined) ||
+ 'https://soroban-testnet.stellar.org',
+ walletNetwork: WalletNetwork.TESTNET,
+ },
+ mainnet: {
+ name: 'mainnet',
+ displayName: 'Mainnet',
+ networkPassphrase: 'Public Global Stellar Network ; September 2015',
+ horizonUrl:
+ (import.meta.env.VITE_MAINNET_HORIZON_URL as string | undefined) ||
+ 'https://horizon.stellar.org',
+ rpcUrl:
+ (import.meta.env.VITE_MAINNET_RPC_URL as string | undefined) || 'https://horizon.stellar.org',
+ walletNetwork: WalletNetwork.PUBLIC,
+ },
+};
+
+function getInitialNetwork(): NetworkName {
+ const stored = localStorage.getItem(STORAGE_KEY);
+ if (stored === 'testnet' || stored === 'mainnet') return stored;
+ return import.meta.env.MODE === 'production' ? 'mainnet' : 'testnet';
+}
+
+export const NetworkProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
+ const [network, setNetwork] = useState(getInitialNetwork);
+ const [contracts, setContracts] = useState(null);
+ const queryClient = useQueryClient();
+
+ // Fetch contract registry whenever network changes
+ useEffect(() => {
+ fetchContractRegistry(network)
+ .then(setContracts)
+ .catch(() => setContracts(null));
+ }, [network]);
+
+ const switchNetwork = useCallback(
+ (newNetwork: NetworkName) => {
+ // Clear React Query cache
+ queryClient.clear();
+
+ // Clear network-sensitive localStorage keys
+ localStorage.removeItem('pending-claims');
+ localStorage.removeItem('payroll-scheduler-draft');
+
+ // Persist preference
+ localStorage.setItem(STORAGE_KEY, newNetwork);
+
+ setNetwork(newNetwork);
+ },
+ [queryClient]
+ );
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/frontend/src/providers/SocketProvider.tsx b/frontend/src/providers/SocketProvider.tsx
index 3c6199ba..74e965bb 100644
--- a/frontend/src/providers/SocketProvider.tsx
+++ b/frontend/src/providers/SocketProvider.tsx
@@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
import { io, Socket } from 'socket.io-client';
import { useNotification } from '../hooks/useNotification';
import { SocketContext } from '../hooks/useSocket';
+import { useNetwork } from '../hooks/useNetwork';
// Assuming backend is running on port 3000
const SOCKET_URL = (import.meta.env.VITE_API_URL as string) || 'http://localhost:3000';
@@ -10,6 +11,7 @@ export const SocketProvider: React.FC<{ children: React.ReactNode }> = ({ childr
const [socket, setSocket] = useState(null);
const [connected, setConnected] = useState(false);
const { notifySuccess, notifyError } = useNotification();
+ const { network } = useNetwork();
useEffect(() => {
const newSocket = io(SOCKET_URL, {
@@ -40,7 +42,9 @@ export const SocketProvider: React.FC<{ children: React.ReactNode }> = ({ childr
return () => {
newSocket.disconnect();
};
- }, [notifySuccess, notifyError]);
+ // Re-connect with a clean socket on network change to clear all subscriptions
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [notifySuccess, notifyError, network]);
const subscribeToTransaction = (transactionId: string) => {
if (socket && connected) {
diff --git a/frontend/src/providers/WalletProvider.tsx b/frontend/src/providers/WalletProvider.tsx
index 6d8f8e4a..1b2aa93e 100644
--- a/frontend/src/providers/WalletProvider.tsx
+++ b/frontend/src/providers/WalletProvider.tsx
@@ -1,7 +1,6 @@
import React, { useEffect, useState, useRef } from 'react';
import {
StellarWalletsKit,
- WalletNetwork,
FreighterModule,
xBullModule,
LobstrModule,
@@ -9,6 +8,7 @@ import {
import { useTranslation } from 'react-i18next';
import { useNotification } from '../hooks/useNotification';
import { WalletContext } from '../hooks/useWallet';
+import { useNetwork } from '../hooks/useNetwork';
export const WalletProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [address, setAddress] = useState(null);
@@ -17,14 +17,19 @@ export const WalletProvider: React.FC<{ children: React.ReactNode }> = ({ childr
const kitRef = useRef(null);
const { t } = useTranslation();
const { notify, notifySuccess, notifyError } = useNotification();
+ const { config } = useNetwork();
useEffect(() => {
+ // Disconnect any previously connected wallet when the network changes
+ setAddress(null);
+ setWalletName(null);
+
const newKit = new StellarWalletsKit({
- network: WalletNetwork.TESTNET,
+ network: config.walletNetwork,
modules: [new FreighterModule(), new xBullModule(), new LobstrModule()],
});
kitRef.current = newKit;
- }, []);
+ }, [config.walletNetwork]);
const connect = async () => {
const kit = kitRef.current;
diff --git a/frontend/src/services/contractRegistry.ts b/frontend/src/services/contractRegistry.ts
new file mode 100644
index 00000000..0790a916
--- /dev/null
+++ b/frontend/src/services/contractRegistry.ts
@@ -0,0 +1,34 @@
+import { ContractRegistry, NetworkName } from '../hooks/useNetwork';
+
+const API_BASE = '/api/v1';
+
+const STATIC_CONTRACTS: Record = {
+ testnet: {
+ bulkPayment: '',
+ crossAssetPayment: '',
+ vestingEscrow: '',
+ revenueSplit: '',
+ },
+ mainnet: {
+ bulkPayment: '',
+ crossAssetPayment: '',
+ vestingEscrow: '',
+ revenueSplit: '',
+ },
+};
+
+/**
+ * Fetches the contract registry for the given network from the backend.
+ * Falls back to static placeholder config when the endpoint is unavailable
+ * (pending implementation of issue #078).
+ */
+export async function fetchContractRegistry(network: NetworkName): Promise {
+ try {
+ const res = await fetch(`${API_BASE}/registry/contracts?network=${network}`);
+ if (!res.ok) return STATIC_CONTRACTS[network];
+ const data = (await res.json()) as ContractRegistry;
+ return data;
+ } catch {
+ return STATIC_CONTRACTS[network];
+ }
+}
diff --git a/frontend/src/services/feeEstimation.ts b/frontend/src/services/feeEstimation.ts
index 119c9949..5b44c96c 100644
--- a/frontend/src/services/feeEstimation.ts
+++ b/frontend/src/services/feeEstimation.ts
@@ -123,10 +123,11 @@ function deriveCongestionLevel(usage: number): CongestionLevel {
}
/**
- * Resolves the Horizon base URL from the `PUBLIC_STELLAR_HORIZON_URL` env var.
- * Falls back to the public Stellar testnet if the variable is not set.
+ * Resolves the Horizon base URL.
+ * Priority: explicit override → `VITE_TESTNET_HORIZON_URL` env var → testnet fallback.
*/
-function getHorizonUrl(): string {
+function getHorizonUrl(override?: string): string {
+ if (override) return override.replace(/\/+$/, '');
const envUrl = import.meta.env.PUBLIC_STELLAR_HORIZON_URL as string | undefined;
return envUrl?.replace(/\/+$/, '') || 'https://horizon-testnet.stellar.org';
}
@@ -137,9 +138,11 @@ function getHorizonUrl(): string {
/**
* Fetches the raw fee statistics from the Horizon `/fee_stats` endpoint.
+ * Pass an explicit `horizonUrl` (from `useNetwork().config.horizonUrl`) to target
+ * the correct network at runtime.
*/
-export async function fetchFeeStats(): Promise {
- const url = `${getHorizonUrl()}/fee_stats`;
+export async function fetchFeeStats(horizonUrl?: string): Promise {
+ const url = `${getHorizonUrl(horizonUrl)}/fee_stats`;
const response = await fetch(url);
if (!response.ok) {
@@ -151,9 +154,11 @@ export async function fetchFeeStats(): Promise {
/**
* Fetches fee stats and returns a processed `FeeRecommendation`.
+ * Pass an explicit `horizonUrl` (from `useNetwork().config.horizonUrl`) to target
+ * the correct network at runtime.
*/
-export async function getFeeRecommendation(): Promise {
- const stats = await fetchFeeStats();
+export async function getFeeRecommendation(horizonUrl?: string): Promise {
+ const stats = await fetchFeeStats(horizonUrl);
const baseFee = Number(stats.last_ledger_base_fee);
const ledgerCapacityUsage = parseFloat(stats.ledger_capacity_usage);
@@ -199,11 +204,15 @@ export async function getFeeRecommendation(): Promise {
* - Low congestion → 1.0×
* - Moderate → 1.2×
* - High → 1.5×
+ *
+ * Pass an explicit `horizonUrl` (from `useNetwork().config.horizonUrl`) to target
+ * the correct network at runtime.
*/
export async function estimateBatchPaymentBudget(
- transactionCount: number
+ transactionCount: number,
+ horizonUrl?: string
): Promise {
- const recommendation = await getFeeRecommendation();
+ const recommendation = await getFeeRecommendation(horizonUrl);
const margin = SAFETY_MARGIN[recommendation.congestionLevel];
const feePerTransaction = Math.ceil(recommendation.recommendedFee * margin);
const totalBudget = feePerTransaction * transactionCount;
diff --git a/frontend/src/services/transactionSimulation.ts b/frontend/src/services/transactionSimulation.ts
index 441ed30b..871bdeb9 100644
--- a/frontend/src/services/transactionSimulation.ts
+++ b/frontend/src/services/transactionSimulation.ts
@@ -95,8 +95,10 @@ export interface SimulationResult {
export interface SimulationOptions {
/** The transaction envelope XDR to simulate */
envelopeXdr: string;
- /** Optional Horizon URL override */
+ /** Optional Horizon URL override (pass `useNetwork().config.horizonUrl` for runtime network) */
horizonUrl?: string;
+ /** Optional Soroban RPC URL override (pass `useNetwork().config.rpcUrl` for runtime network) */
+ rpcUrl?: string;
}
/**
@@ -205,9 +207,10 @@ function parseHorizonError(errorBody: HorizonTransactionError): SimulationError[
* endpoint is used instead.
*/
export async function simulateTransaction(options: SimulationOptions): Promise {
- const { envelopeXdr, horizonUrl } = options;
+ const { envelopeXdr, horizonUrl, rpcUrl: rpcUrlOverride } = options;
const baseUrl = horizonUrl ?? getHorizonUrl();
const rpcUrl =
+ rpcUrlOverride?.replace(/\/+$/, '') ||
(import.meta.env.PUBLIC_STELLAR_RPC_URL as string | undefined)?.replace(/\/+$/, '') ||
'https://soroban-testnet.stellar.org';
diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts
index 3160e4fd..078ee1db 100644
--- a/frontend/src/vite-env.d.ts
+++ b/frontend/src/vite-env.d.ts
@@ -7,6 +7,16 @@ declare module '*.module.css' {
interface ImportMetaEnv {
readonly VITE_SENTRY_DSN?: string;
+ readonly VITE_API_URL?: string;
+ // Network-specific URL overrides (optional — sensible defaults are built in)
+ readonly VITE_TESTNET_HORIZON_URL?: string;
+ readonly VITE_TESTNET_RPC_URL?: string;
+ readonly VITE_MAINNET_HORIZON_URL?: string;
+ readonly VITE_MAINNET_RPC_URL?: string;
+ // Legacy env vars kept for backwards compat with existing services
+ readonly PUBLIC_STELLAR_HORIZON_URL?: string;
+ readonly PUBLIC_STELLAR_RPC_URL?: string;
+ readonly MODE: string;
}
interface ImportMeta {
From 365905512b2e5c934d1d162f0d98a16a7686b850 Mon Sep 17 00:00:00 2001
From: Thompson <140930314+Godbrand0@users.noreply.github.com>
Date: Thu, 26 Feb 2026 07:45:58 -0500
Subject: [PATCH 2/2] refactor: simplify environment variable access by
removing explicit type assertions.
---
frontend/src/providers/NetworkProvider.tsx | 11 ++++-------
frontend/src/providers/SocketProvider.tsx | 1 -
frontend/src/services/feeEstimation.ts | 2 +-
frontend/src/services/transactionSimulation.ts | 4 ++--
4 files changed, 7 insertions(+), 11 deletions(-)
diff --git a/frontend/src/providers/NetworkProvider.tsx b/frontend/src/providers/NetworkProvider.tsx
index cb9707cc..a05c09c7 100644
--- a/frontend/src/providers/NetworkProvider.tsx
+++ b/frontend/src/providers/NetworkProvider.tsx
@@ -17,11 +17,9 @@ const NETWORK_CONFIGS: Record = {
displayName: 'Testnet',
networkPassphrase: 'Test SDF Network ; September 2015',
horizonUrl:
- (import.meta.env.VITE_TESTNET_HORIZON_URL as string | undefined) ||
- 'https://horizon-testnet.stellar.org',
+ import.meta.env.VITE_TESTNET_HORIZON_URL || 'https://horizon-testnet.stellar.org',
rpcUrl:
- (import.meta.env.VITE_TESTNET_RPC_URL as string | undefined) ||
- 'https://soroban-testnet.stellar.org',
+ import.meta.env.VITE_TESTNET_RPC_URL || 'https://soroban-testnet.stellar.org',
walletNetwork: WalletNetwork.TESTNET,
},
mainnet: {
@@ -29,10 +27,9 @@ const NETWORK_CONFIGS: Record = {
displayName: 'Mainnet',
networkPassphrase: 'Public Global Stellar Network ; September 2015',
horizonUrl:
- (import.meta.env.VITE_MAINNET_HORIZON_URL as string | undefined) ||
- 'https://horizon.stellar.org',
+ import.meta.env.VITE_MAINNET_HORIZON_URL || 'https://horizon.stellar.org',
rpcUrl:
- (import.meta.env.VITE_MAINNET_RPC_URL as string | undefined) || 'https://horizon.stellar.org',
+ import.meta.env.VITE_MAINNET_RPC_URL || 'https://horizon.stellar.org',
walletNetwork: WalletNetwork.PUBLIC,
},
};
diff --git a/frontend/src/providers/SocketProvider.tsx b/frontend/src/providers/SocketProvider.tsx
index 74e965bb..0d93eed2 100644
--- a/frontend/src/providers/SocketProvider.tsx
+++ b/frontend/src/providers/SocketProvider.tsx
@@ -43,7 +43,6 @@ export const SocketProvider: React.FC<{ children: React.ReactNode }> = ({ childr
newSocket.disconnect();
};
// Re-connect with a clean socket on network change to clear all subscriptions
- // eslint-disable-next-line react-hooks/exhaustive-deps
}, [notifySuccess, notifyError, network]);
const subscribeToTransaction = (transactionId: string) => {
diff --git a/frontend/src/services/feeEstimation.ts b/frontend/src/services/feeEstimation.ts
index 5b44c96c..0c9aaaf6 100644
--- a/frontend/src/services/feeEstimation.ts
+++ b/frontend/src/services/feeEstimation.ts
@@ -128,7 +128,7 @@ function deriveCongestionLevel(usage: number): CongestionLevel {
*/
function getHorizonUrl(override?: string): string {
if (override) return override.replace(/\/+$/, '');
- const envUrl = import.meta.env.PUBLIC_STELLAR_HORIZON_URL as string | undefined;
+ const envUrl = import.meta.env.PUBLIC_STELLAR_HORIZON_URL;
return envUrl?.replace(/\/+$/, '') || 'https://horizon-testnet.stellar.org';
}
diff --git a/frontend/src/services/transactionSimulation.ts b/frontend/src/services/transactionSimulation.ts
index 871bdeb9..35144ed6 100644
--- a/frontend/src/services/transactionSimulation.ts
+++ b/frontend/src/services/transactionSimulation.ts
@@ -129,7 +129,7 @@ interface HorizonTransactionError {
* Falls back to the public Stellar testnet if the variable is not set.
*/
function getHorizonUrl(): string {
- const envUrl = import.meta.env.PUBLIC_STELLAR_HORIZON_URL as string | undefined;
+ const envUrl = import.meta.env.PUBLIC_STELLAR_HORIZON_URL;
return envUrl?.replace(/\/+$/, '') || 'https://horizon-testnet.stellar.org';
}
@@ -211,7 +211,7 @@ export async function simulateTransaction(options: SimulationOptions): Promise