diff --git a/apps/web/src/providers/StellarWalletProvider.tsx b/apps/web/src/providers/StellarWalletProvider.tsx index d7e9ca8d..942791a9 100644 --- a/apps/web/src/providers/StellarWalletProvider.tsx +++ b/apps/web/src/providers/StellarWalletProvider.tsx @@ -13,14 +13,22 @@ import { WalletNetwork, allowAllModules, } from "@creit.tech/stellar-wallets-kit"; -import { AlertCircle } from "lucide-react"; +import { AlertCircle, AlertTriangle, ArrowRightLeft } from "lucide-react"; import { safeGetItem, safeSetItem, safeRemoveItem, isStorageAvailable } from "@/utils/safe-storage"; import { isValidStellarAddress } from "@/utils/stellar-validation"; import { offrampService } from "@/services/offramp.service"; import { notify } from "@/utils/notification"; -import { isLockedWalletError } from "@/utils/wallet-errors"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; export type WalletId = string; export type ConnectionStatus = @@ -30,6 +38,28 @@ export type ConnectionStatus = | "disconnecting" | "locked"; +/** + * Maps each WalletNetwork enum value to its canonical Stellar network passphrase. + * Used to detect mismatches between the wallet extension's active network and the + * network the application is configured to target. + */ +export const NETWORK_PASSPHRASES: Record = { + [WalletNetwork.PUBLIC]: "Public Global Stellar Network ; September 2015", + [WalletNetwork.TESTNET]: "Test SDF Network ; September 2015", + [WalletNetwork.FUTURENET]: "Test SDF Future Network ; October 2022", + [WalletNetwork.SANDBOX]: "Local Sandbox Stellar Network ; September 2015", + [WalletNetwork.STANDALONE]: "Standalone Network ; February 2017", +}; + +/** Human-readable display names for each WalletNetwork value. */ +const NETWORK_DISPLAY_NAMES: Record = { + [WalletNetwork.PUBLIC]: "Mainnet", + [WalletNetwork.TESTNET]: "Testnet", + [WalletNetwork.FUTURENET]: "Futurenet", + [WalletNetwork.SANDBOX]: "Sandbox", + [WalletNetwork.STANDALONE]: "Standalone", +}; + interface WalletContextType { connect: (walletId: WalletId) => Promise; disconnect: () => Promise; @@ -46,6 +76,8 @@ interface WalletContextType { closeModal: () => void; isModalOpen: boolean; supportedWallets: { id: WalletId; name: string; icon: string }[]; + /** True when the connected wallet's network passphrase does not match the app's configured network. */ + networkMismatch: boolean; } const WalletContext = createContext(undefined); @@ -100,6 +132,17 @@ export const StellarWalletProvider = ({ const [isModalOpen, setIsModalOpen] = useState(false); const [isPersistenceAvailable, setIsPersistenceAvailable] = useState(true); + /** + * Tracks whether the wallet extension's active network passphrase mismatches + * the application's configured network. When true, the NetworkMismatchModal is shown. + */ + const [networkMismatch, setNetworkMismatch] = useState(false); + /** + * Stores the network name reported by the wallet extension so the mismatch modal + * can display an informative message ("Your wallet is on Mainnet, app needs Testnet"). + */ + const [walletNetworkName, setWalletNetworkName] = useState(""); + // Holds the AbortController for the current in-flight connection attempt. // Aborting it signals connect() to discard any resolved address. const connectionAbortRef = useRef(null); @@ -164,6 +207,8 @@ export const StellarWalletProvider = ({ setConnectionStatus("disconnecting"); setAddress(null); setSelectedWalletId(null); + setNetworkMismatch(false); + setWalletNetworkName(""); safeRemoveItem("stellar_wallet_address"); safeRemoveItem("@fundable/web:selected_wallet"); safeRemoveItem("stellar_wallet_network"); @@ -205,6 +250,58 @@ export const StellarWalletProvider = ({ rango: "https://app.rango.exchange/", }; + /** + * Checks whether the wallet extension's active network passphrase matches the + * application's configured network. Returns `true` when they match (safe to + * proceed), `false` on a mismatch, and `true` when the wallet doesn't expose a + * network API (fail-open so legacy wallets keep working). + * + * On a mismatch the function also updates `networkMismatch` and + * `walletNetworkName` state so the blocking modal is displayed to the user. + */ + const checkNetworkPassphrase = useCallback( + async (walletKit: StellarWalletsKit): Promise => { + try { + // Not all wallet adapters implement getNetwork(); guard against that. + if (typeof walletKit.getNetwork !== "function") return true; + + const walletNetworkInfo = await walletKit.getNetwork(); + const walletPassphrase: string = + typeof walletNetworkInfo === "string" + ? walletNetworkInfo + : (walletNetworkInfo as { networkPassphrase?: string; network?: string }) + ?.networkPassphrase ?? + (walletNetworkInfo as { networkPassphrase?: string; network?: string }) + ?.network ?? + ""; + + const expectedPassphrase = NETWORK_PASSPHRASES[network]; + + if (!walletPassphrase || walletPassphrase === expectedPassphrase) { + // Passphrase matches or wallet didn't provide one — safe to proceed. + return true; + } + + // Determine the human-readable name of the wallet's network for the modal. + const detectedNetworkEntry = Object.entries(NETWORK_PASSPHRASES).find( + ([, passphrase]) => passphrase === walletPassphrase, + ); + const detectedNetworkName = detectedNetworkEntry + ? NETWORK_DISPLAY_NAMES[detectedNetworkEntry[0] as WalletNetwork] + : `Unknown (${walletPassphrase.slice(0, 30)}…)`; + + setWalletNetworkName(detectedNetworkName); + setNetworkMismatch(true); + return false; + } catch { + // If the check itself throws (e.g. wallet doesn't support getNetwork), + // fail-open so we don't block existing wallets. + return true; + } + }, + [network], + ); + const connect = useCallback(async (walletId: WalletId) => { if (!kit) return; @@ -250,6 +347,20 @@ export const StellarWalletProvider = ({ ); } + // ── Network passphrase mismatch check ───────────────────────────────── + // Verify that the wallet extension is connected to the same Stellar network + // that this application is configured to target. A mismatch means the user + // would end up signing transactions intended for Testnet contracts with a + // Mainnet wallet (or vice versa), which produces invalid / rejected txs. + const passphraseOk = await checkNetworkPassphrase(kit); + if (!passphraseOk) { + // The modal is now visible. Reset connecting state and bail out — the + // user must switch networks in their wallet extension and reconnect. + setConnectionStatus("idle"); + return; + } + // ────────────────────────────────────────────────────────────────────── + setAddress(resolvedAddress); setSelectedWalletId(walletId); setConnectionStatus("connected"); @@ -316,12 +427,24 @@ export const StellarWalletProvider = ({ connectionAbortRef.current = null; } } - }, [kit, network]); + }, [kit, network, checkNetworkPassphrase]); const signTransaction = useCallback( async (xdr: string) => { if (!kit || !address) throw new Error("Wallet not connected"); + // ── Network passphrase mismatch check ───────────────────────────────── + // Re-verify before every signing attempt: the user could have switched + // networks in their wallet extension after the initial connection. + const passphraseOk = await checkNetworkPassphrase(kit); + if (!passphraseOk) { + throw new Error( + `Network mismatch: your wallet is on ${walletNetworkName} but this app targets ` + + `${NETWORK_DISPLAY_NAMES[network]}. Please switch your wallet to ` + + `${NETWORK_DISPLAY_NAMES[network]} and try again.`, + ); + } + // ────────────────────────────────────────────────────────────────────── let timeoutId: ReturnType; const timeoutPromise = new Promise((_, reject) => { timeoutId = setTimeout(() => { @@ -339,12 +462,34 @@ export const StellarWalletProvider = ({ clearTimeout(timeoutId!); } }, - [kit, address], + [kit, address, network, walletNetworkName, checkNetworkPassphrase], ); const openModal = useCallback(() => setIsModalOpen(true), []); const closeModal = useCallback(() => setIsModalOpen(false), []); + /** Dismiss the mismatch modal without disconnecting. */ + const dismissMismatchModal = useCallback(() => { + setNetworkMismatch(false); + setWalletNetworkName(""); + }, []); + + /** + * Switch the app's configured network to match the wallet's network. + * This calls `setNetwork` which disconnects first, then re-initialises the kit. + */ + const switchAppNetwork = useCallback(async () => { + // Find the WalletNetwork enum value whose display name matches walletNetworkName + const targetEntry = Object.entries(NETWORK_DISPLAY_NAMES).find( + ([, displayName]) => displayName === walletNetworkName, + ); + setNetworkMismatch(false); + setWalletNetworkName(""); + if (targetEntry) { + await setNetwork(targetEntry[0] as WalletNetwork); + } + }, [walletNetworkName, setNetwork]); + return ( {children} + + {/* ── Network mismatch modal ──────────────────────────────────────── */} + !open && dismissMismatchModal()}> + e.preventDefault()} + > + +
+
+
+ Network Mismatch Detected +
+ + Your wallet is connected to{" "} + {walletNetworkName}, but this + app is configured for{" "} + + {NETWORK_DISPLAY_NAMES[network]} + + . + +
+ +
+ Submitting a transaction signed on the wrong network will cause it to be rejected by + Stellar validators. Please switch your wallet extension to{" "} + {NETWORK_DISPLAY_NAMES[network]}{" "} + before proceeding, or switch the app network to match your wallet. +
+ + + + + +
+
+ {/* ────────────────────────────────────────────────────────────────── */} + {!isPersistenceAvailable && (