From 50a4cf97b812c0847bfe3c2392a386a68dce89b4 Mon Sep 17 00:00:00 2001 From: Abdullah Azmi Date: Mon, 19 Oct 2026 03:22:59 +0000 Subject: [PATCH] feat(frontend): add network switch, re-check action, and bold badges to WalletMismatchBanner Enhance WalletMismatchBanner with bold badges displaying both the wallet's reported network and the app's active network, a "Switch App to Wallet Network" button that updates the app's active network setting when supported, and a "Re-check Connection" button that re-queries the connected wallet session. Fixes #1642 --- .../wallet/WalletMismatchBanner.test.tsx | 219 ++++++++++++++++++ .../wallet/WalletMismatchBanner.tsx | 108 ++++++++- 2 files changed, 323 insertions(+), 4 deletions(-) create mode 100644 frontend/src/components/wallet/WalletMismatchBanner.test.tsx diff --git a/frontend/src/components/wallet/WalletMismatchBanner.test.tsx b/frontend/src/components/wallet/WalletMismatchBanner.test.tsx new file mode 100644 index 00000000..f582f9c7 --- /dev/null +++ b/frontend/src/components/wallet/WalletMismatchBanner.test.tsx @@ -0,0 +1,219 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { + WalletMismatchBanner, + resolveSupportedNetworkId, +} from "./WalletMismatchBanner"; + +const mockUseWallet = vi.fn(); +const mockUseNetwork = vi.fn(); + +vi.mock("@/context/wallet-context", () => ({ + useWallet: () => mockUseWallet(), +})); + +vi.mock("@/context/NetworkContext", () => ({ + useNetwork: () => mockUseNetwork(), +})); + +describe("resolveSupportedNetworkId", () => { + it("maps mainnet and public passphrases to mainnet", () => { + expect(resolveSupportedNetworkId("Mainnet")).toBe("mainnet"); + expect( + resolveSupportedNetworkId("Public Global Stellar Network ; September 2015"), + ).toBe("mainnet"); + }); + + it("maps testnet and SDF testnet passphrases to testnet", () => { + expect(resolveSupportedNetworkId("Testnet")).toBe("testnet"); + expect(resolveSupportedNetworkId("Test SDF Network ; September 2015")).toBe( + "testnet", + ); + }); + + it("maps futurenet and sandbox strings to their respective network IDs", () => { + expect(resolveSupportedNetworkId("Futurenet")).toBe("futurenet"); + expect( + resolveSupportedNetworkId("Test SDF Future Network ; October 2022"), + ).toBe("futurenet"); + expect(resolveSupportedNetworkId("Local Sandbox")).toBe("sandbox"); + expect(resolveSupportedNetworkId("Standalone Network ; February 2017")).toBe( + "sandbox", + ); + }); + + it("returns null for unknown custom networks or empty strings", () => { + expect(resolveSupportedNetworkId("")).toBeNull(); + expect(resolveSupportedNetworkId("Custom Private Net")).toBeNull(); + }); +}); + +describe("WalletMismatchBanner", () => { + const connectMock = vi.fn(); + const setNetworkIdMock = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders nothing when no wallet session is active", () => { + mockUseWallet.mockReturnValue({ + session: null, + connect: connectMock, + selectedWalletId: null, + }); + mockUseNetwork.mockReturnValue({ + network: { id: "testnet", name: "Testnet" }, + networkId: "testnet", + setNetworkId: setNetworkIdMock, + isHydrated: true, + }); + + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it("renders nothing when wallet network matches app network", () => { + mockUseWallet.mockReturnValue({ + session: { + walletId: "freighter", + publicKey: "GABC123", + network: "Test SDF Network ; September 2015", + }, + connect: connectMock, + selectedWalletId: "freighter", + }); + mockUseNetwork.mockReturnValue({ + network: { id: "testnet", name: "Testnet" }, + networkId: "testnet", + setNetworkId: setNetworkIdMock, + isHydrated: true, + }); + + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it("renders role='alert' banner with bold badges for wallet and app networks when mismatched", () => { + mockUseWallet.mockReturnValue({ + session: { + walletId: "freighter", + publicKey: "GABC123", + network: "Public Global Stellar Network ; September 2015", + }, + connect: connectMock, + selectedWalletId: "freighter", + }); + mockUseNetwork.mockReturnValue({ + network: { id: "testnet", name: "Testnet" }, + networkId: "testnet", + setNetworkId: setNetworkIdMock, + isHydrated: true, + }); + + render(); + + const alert = screen.getByRole("alert"); + expect(alert).toBeInTheDocument(); + + const walletBadge = screen.getByTestId("wallet-network-badge"); + const appBadge = screen.getByTestId("app-network-badge"); + expect(walletBadge.tagName).toBe("STRONG"); + expect(walletBadge).toHaveTextContent("Mainnet"); + expect(appBadge.tagName).toBe("STRONG"); + expect(appBadge).toHaveTextContent("Testnet"); + }); + + it("updates app network setting when 'Switch App to Wallet Network' is clicked", () => { + mockUseWallet.mockReturnValue({ + session: { + walletId: "freighter", + publicKey: "GABC123", + network: "Mainnet", + }, + connect: connectMock, + selectedWalletId: "freighter", + }); + mockUseNetwork.mockReturnValue({ + network: { id: "testnet", name: "Testnet" }, + networkId: "testnet", + setNetworkId: setNetworkIdMock, + isHydrated: true, + }); + + render(); + + const switchButton = screen.getByRole("button", { + name: /Switch App to Wallet Network/i, + }); + expect(switchButton).not.toBeDisabled(); + fireEvent.click(switchButton); + + expect(setNetworkIdMock).toHaveBeenCalledTimes(1); + expect(setNetworkIdMock).toHaveBeenCalledWith("mainnet"); + }); + + it("disables 'Switch App to Wallet Network' when wallet reports an unsupported custom network", () => { + mockUseWallet.mockReturnValue({ + session: { + walletId: "freighter", + publicKey: "GABC123", + network: "Custom Private Chain", + }, + connect: connectMock, + selectedWalletId: "freighter", + }); + mockUseNetwork.mockReturnValue({ + network: { id: "testnet", name: "Testnet" }, + networkId: "testnet", + setNetworkId: setNetworkIdMock, + isHydrated: true, + }); + + render(); + + const switchButton = screen.getByRole("button", { + name: /Switch App to Wallet Network/i, + }); + expect(switchButton).toBeDisabled(); + }); + + it("queries wallet connection on 'Re-check Connection' click and dismisses banner when resolved", () => { + let currentSession = { + walletId: "freighter" as const, + publicKey: "GABC123", + network: "Mainnet", + }; + + mockUseWallet.mockImplementation(() => ({ + session: currentSession, + connect: connectMock, + selectedWalletId: "freighter", + })); + mockUseNetwork.mockReturnValue({ + network: { id: "testnet", name: "Testnet" }, + networkId: "testnet", + setNetworkId: setNetworkIdMock, + isHydrated: true, + }); + + const { rerender } = render(); + expect(screen.getByRole("alert")).toBeInTheDocument(); + + const recheckButton = screen.getByRole("button", { + name: /Re-check Connection/i, + }); + fireEvent.click(recheckButton); + expect(connectMock).toHaveBeenCalledTimes(1); + expect(connectMock).toHaveBeenCalledWith("freighter"); + + // Simulate wallet session updating to match app network after re-check + currentSession = { + walletId: "freighter", + publicKey: "GABC123", + network: "Testnet", + }; + rerender(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/wallet/WalletMismatchBanner.tsx b/frontend/src/components/wallet/WalletMismatchBanner.tsx index 005a2573..cfb55972 100644 --- a/frontend/src/components/wallet/WalletMismatchBanner.tsx +++ b/frontend/src/components/wallet/WalletMismatchBanner.tsx @@ -1,12 +1,112 @@ "use client"; -import { AlertTriangle, RefreshCw } from "lucide-react"; +import { AlertTriangle, ArrowLeftRight, RefreshCw } from "lucide-react"; import { useWallet } from "@/context/wallet-context"; import { useNetwork } from "@/context/NetworkContext"; import { formatNetwork } from "@/lib/wallet"; +import type { NetworkId } from "@/lib/stellar-config"; + +/** + * Resolves a wallet-reported network string or passphrase to a supported app NetworkId. + */ +export function resolveSupportedNetworkId(walletNetwork: string): NetworkId | null { + const raw = walletNetwork.trim().toLowerCase(); + if (!raw) return null; + + if (raw.includes("future")) { + return "futurenet"; + } + + if (raw.includes("sandbox") || raw.includes("standalone") || raw.includes("local")) { + return "sandbox"; + } + + const formatted = formatNetwork(walletNetwork).trim().toLowerCase(); + if (formatted === "mainnet") { + return "mainnet"; + } + + if (formatted === "testnet") { + return "testnet"; + } + + return null; +} export function WalletMismatchBanner() { - const { session, connect, selectedWalletId } = useWallet(); const { network } = useNetwork(); - if (!session || formatNetwork(session.network).toLowerCase() === network.name.toLowerCase()) return null; - return
Wallet is on {formatNetwork(session.network)}; app is using {network.name}. Transactions may fail.
; + const { session, connect, selectedWalletId } = useWallet(); + const { network, setNetworkId } = useNetwork(); + + if (!session) return null; + + const walletNetworkLabel = formatNetwork(session.network); + const targetNetworkId = resolveSupportedNetworkId(session.network); + + if ( + walletNetworkLabel.toLowerCase() === network.name.toLowerCase() || + (targetNetworkId !== null && targetNetworkId === network.id) + ) { + return null; + } + + const handleSwitchAppNetwork = () => { + if (targetNetworkId) { + setNetworkId(targetNetworkId); + } + }; + + const handleRecheckConnection = () => { + const walletIdToCheck = selectedWalletId ?? session.walletId; + if (walletIdToCheck) { + void connect(walletIdToCheck); + } + }; + + return ( +
+ + + +
+ + +
+
+ ); } \ No newline at end of file