('account');
@@ -242,13 +245,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..a05c09c7
--- /dev/null
+++ b/frontend/src/providers/NetworkProvider.tsx
@@ -0,0 +1,85 @@
+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 || 'https://horizon-testnet.stellar.org',
+ rpcUrl:
+ import.meta.env.VITE_TESTNET_RPC_URL || '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 || 'https://horizon.stellar.org',
+ rpcUrl:
+ import.meta.env.VITE_MAINNET_RPC_URL || '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..0d93eed2 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,8 @@ 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
+ }, [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 5bef1c4d..0a810e1b 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';
const LAST_WALLET_STORAGE_KEY = 'payd:last_wallet_name';
@@ -33,15 +33,20 @@ 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);
setWalletExtensionAvailable(hasAnyWalletExtension());
const newKit = new StellarWalletsKit({
- network: WalletNetwork.TESTNET,
+ network: config.walletNetwork,
modules: [new FreighterModule(), new xBullModule(), new LobstrModule()],
});
kitRef.current = newKit;
+ }, [config.walletNetwork]);
const attemptSilentReconnect = async () => {
const lastWalletName = localStorage.getItem(LAST_WALLET_STORAGE_KEY);
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..0c9aaaf6 100644
--- a/frontend/src/services/feeEstimation.ts
+++ b/frontend/src/services/feeEstimation.ts
@@ -123,11 +123,12 @@ 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 {
- const envUrl = import.meta.env.PUBLIC_STELLAR_HORIZON_URL as string | undefined;
+function getHorizonUrl(override?: string): string {
+ if (override) return override.replace(/\/+$/, '');
+ const envUrl = import.meta.env.PUBLIC_STELLAR_HORIZON_URL;
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..35144ed6 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;
}
/**
@@ -127,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';
}
@@ -205,10 +207,11 @@ 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 =
- (import.meta.env.PUBLIC_STELLAR_RPC_URL as string | undefined)?.replace(/\/+$/, '') ||
+ rpcUrlOverride?.replace(/\/+$/, '') ||
+ import.meta.env.PUBLIC_STELLAR_RPC_URL?.replace(/\/+$/, '') ||
'https://soroban-testnet.stellar.org';
const simulatedAt = new Date();
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 {