diff --git a/frontend/src/components/AppLayout.tsx b/frontend/src/components/AppLayout.tsx index 1ec8b82c..ece015cb 100644 --- a/frontend/src/components/AppLayout.tsx +++ b/frontend/src/components/AppLayout.tsx @@ -3,6 +3,8 @@ import { Outlet, NavLink, useLocation } from 'react-router-dom'; import ConnectAccount from '../components/ConnectAccount'; import AppNav from './AppNav'; import ThemeToggle from './ThemeToggle'; +import TestnetBanner from './TestnetBanner'; +import { useNetwork } from '../hooks/useNetwork'; // ── Page Wrapper ─────────────────────── const PageWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( @@ -12,6 +14,7 @@ const PageWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( // ── Layout ──────────────────────────── const AppLayout: React.FC = () => { const location = useLocation(); + const { config } = useNetwork(); return (
{
+ {/* Testnet Banner — rendered in normal flow below the fixed header */} +
+ +
+ {/* Main */} -
+
@@ -76,7 +84,7 @@ const AppLayout: React.FC = () => {
- STELLAR NETWORK · MAINNET + STELLAR NETWORK · {config.displayName.toUpperCase()}
diff --git a/frontend/src/components/NetworkSwitcher.tsx b/frontend/src/components/NetworkSwitcher.tsx new file mode 100644 index 00000000..328523f9 --- /dev/null +++ b/frontend/src/components/NetworkSwitcher.tsx @@ -0,0 +1,96 @@ +import React, { useState } from 'react'; +import { useNetwork } from '../hooks/useNetwork'; +import type { NetworkName } from '../hooks/useNetwork'; + +/** + * Pill-style toggle between Testnet and Mainnet. + * Shows a confirmation modal before switching to explain side-effects. + */ +const NetworkSwitcher: React.FC = () => { + const { network, switchNetwork } = useNetwork(); + const [pending, setPending] = useState(null); + + const handleRequest = (target: NetworkName) => { + if (target === network) return; + setPending(target); + }; + + const handleConfirm = () => { + if (pending) switchNetwork(pending); + setPending(null); + }; + + const handleCancel = () => setPending(null); + + return ( + <> + {/* Toggle pill */} +
+ + +
+ + {/* Confirmation modal */} + {pending && ( +
+
+

+ Switch to{' '} + + {pending === 'testnet' ? 'Testnet' : 'Mainnet'} + + ? +

+
+

Switching networks will:

+
    +
  • Disconnect your wallet
  • +
  • Clear all cached balances and queries
  • +
  • Reset active socket subscriptions
  • +
  • Re-fetch the contract registry for {pending}
  • +
+
+
+ + +
+
+
+ )} + + ); +}; + +export default NetworkSwitcher; diff --git a/frontend/src/components/TestnetBanner.tsx b/frontend/src/components/TestnetBanner.tsx new file mode 100644 index 00000000..24778a9b --- /dev/null +++ b/frontend/src/components/TestnetBanner.tsx @@ -0,0 +1,46 @@ +import React, { useState } from 'react'; +import { useNetwork } from '../hooks/useNetwork'; + +const SESSION_KEY = 'payd-testnet-banner-dismissed'; + +const TestnetBanner: React.FC = () => { + const { isTestnet, switchNetwork } = useNetwork(); + const [dismissed, setDismissed] = useState(() => { + return sessionStorage.getItem(SESSION_KEY) === 'true'; + }); + + if (!isTestnet || dismissed) return null; + + const handleDismiss = () => { + sessionStorage.setItem(SESSION_KEY, 'true'); + setDismissed(true); + }; + + return ( +
+
+ + + Testnet mode — transactions have no real-world value. + +
+
+ + +
+
+ ); +}; + +export default TestnetBanner; diff --git a/frontend/src/hooks/useFeeEstimation.ts b/frontend/src/hooks/useFeeEstimation.ts index 0183d9f9..c707e8d8 100644 --- a/frontend/src/hooks/useFeeEstimation.ts +++ b/frontend/src/hooks/useFeeEstimation.ts @@ -31,7 +31,7 @@ export function useFeeEstimation() { refetch, } = useQuery({ queryKey: FEE_ESTIMATION_QUERY_KEY, - queryFn: getFeeRecommendation, + queryFn: () => getFeeRecommendation(), refetchInterval: POLL_INTERVAL_MS, staleTime: POLL_INTERVAL_MS, }); diff --git a/frontend/src/hooks/useNetwork.ts b/frontend/src/hooks/useNetwork.ts new file mode 100644 index 00000000..daa18336 --- /dev/null +++ b/frontend/src/hooks/useNetwork.ts @@ -0,0 +1,36 @@ +import { createContext, use } from 'react'; +import { WalletNetwork } from '@creit.tech/stellar-wallets-kit'; + +export type NetworkName = 'testnet' | 'mainnet'; + +export interface StellarNetworkConfig { + name: NetworkName; + displayName: string; + networkPassphrase: string; + horizonUrl: string; + rpcUrl: string; + walletNetwork: WalletNetwork; +} + +export interface ContractRegistry { + bulkPayment: string; + crossAssetPayment: string; + vestingEscrow: string; + revenueSplit: string; +} + +export interface NetworkContextType { + network: NetworkName; + config: StellarNetworkConfig; + contracts: ContractRegistry | null; + isTestnet: boolean; + switchNetwork: (network: NetworkName) => void; +} + +export const NetworkContext = createContext(undefined); + +export const useNetwork = () => { + const context = use(NetworkContext); + if (!context) throw new Error('useNetwork must be used within NetworkProvider'); + return context; +}; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index f84f1879..6bda0b7f 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -8,6 +8,7 @@ import { WalletProvider } from './providers/WalletProvider.tsx'; import { NotificationProvider } from './providers/NotificationProvider.tsx'; import { SocketProvider } from './providers/SocketProvider.tsx'; import { ThemeProvider } from './providers/ThemeProvider.tsx'; +import { NetworkProvider } from './providers/NetworkProvider.tsx'; import * as Sentry from '@sentry/react'; import ErrorBoundary from './components/ErrorBoundary'; import ErrorFallback from './components/ErrorFallback'; @@ -30,19 +31,21 @@ const queryClient = new QueryClient(); ReactDOM.createRoot(document.getElementById('root')!).render( - - - - - - {}} />}> - - - - - - - + + + + + + + {}} />}> + + + + + + + + ); diff --git a/frontend/src/pages/AdminPanel.tsx b/frontend/src/pages/AdminPanel.tsx index b2bbb6a1..4c944924 100644 --- a/frontend/src/pages/AdminPanel.tsx +++ b/frontend/src/pages/AdminPanel.tsx @@ -9,6 +9,8 @@ import { Code2, } from 'lucide-react'; import { useNotification } from '../hooks/useNotification'; +import NetworkSwitcher from '../components/NetworkSwitcher'; +import { useNetwork } from '../hooks/useNetwork'; import { useWallet } from '../hooks/useWallet'; import ContractUpgradeTab from '../components/ContractUpgradeTab'; @@ -75,6 +77,7 @@ const TAB_LABELS: Record = { export default function AdminPanel() { const { notifySuccess, notifyError } = useNotification(); + const { config, isTestnet } = useNetwork(); const { address: adminAddress } = useWallet(); const [activeTab, setActiveTab] = useState('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 {