Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
219 changes: 219 additions & 0 deletions frontend/src/components/wallet/WalletMismatchBanner.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<WalletMismatchBanner />);
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(<WalletMismatchBanner />);
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(<WalletMismatchBanner />);

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(<WalletMismatchBanner />);

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(<WalletMismatchBanner />);

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(<WalletMismatchBanner />);
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(<WalletMismatchBanner />);
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
});
108 changes: 104 additions & 4 deletions frontend/src/components/wallet/WalletMismatchBanner.tsx
Original file line number Diff line number Diff line change
@@ -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 <div role="alert" className="flex items-center justify-between gap-4 border-b border-orange-400/30 bg-orange-400/10 px-6 py-2 text-sm text-orange-100"><span className="flex items-center gap-2"><AlertTriangle size={16} />Wallet is on {formatNetwork(session.network)}; app is using {network.name}. Transactions may fail.</span><button onClick={() => { if (selectedWalletId) void connect(selectedWalletId); }} className="flex shrink-0 items-center gap-2 rounded border border-orange-300/40 px-3 py-1 text-xs font-semibold"><RefreshCw size={13} />Reconnect wallet</button></div>;
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 (
<div
role="alert"
className="flex flex-wrap items-center justify-between gap-4 border-b border-orange-400/30 bg-orange-400/10 px-6 py-2 text-sm text-orange-100"
>
<span className="flex flex-wrap items-center gap-2">
<AlertTriangle size={16} className="shrink-0" aria-hidden="true" />
<span>
Wallet is on{" "}
<strong
data-testid="wallet-network-badge"
className="inline-flex items-center rounded bg-orange-400/20 px-2 py-0.5 font-bold text-orange-50"
>
{walletNetworkLabel}
</strong>
; app is using{" "}
<strong
data-testid="app-network-badge"
className="inline-flex items-center rounded bg-orange-400/20 px-2 py-0.5 font-bold text-orange-50"
>
{network.name}
</strong>
. Transactions may fail.
</span>
</span>

<div className="flex shrink-0 items-center gap-2">
<button
type="button"
onClick={handleSwitchAppNetwork}
disabled={!targetNetworkId}
className="flex items-center gap-1.5 rounded border border-orange-300/40 bg-orange-400/15 px-3 py-1 text-xs font-semibold transition-colors hover:bg-orange-400/25 disabled:cursor-not-allowed disabled:opacity-50"
>
<ArrowLeftRight size={13} aria-hidden="true" />
Switch App to Wallet Network
</button>
<button
type="button"
onClick={handleRecheckConnection}
className="flex items-center gap-1.5 rounded border border-orange-300/40 px-3 py-1 text-xs font-semibold transition-colors hover:bg-orange-400/20"
>
<RefreshCw size={13} aria-hidden="true" />
Re-check Connection
</button>
</div>
</div>
);
}
Loading