diff --git a/__tests__/transaction_signer.component.test.tsx b/__tests__/transaction_signer.component.test.tsx new file mode 100644 index 0000000..982e576 --- /dev/null +++ b/__tests__/transaction_signer.component.test.tsx @@ -0,0 +1,279 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import TransactionSigner from "@/app/components/TransactionSigner"; + +describe("TransactionSigner component (#216)", () => { + let warnSpy: ReturnType; + + beforeEach(() => { + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + vi.clearAllMocks(); + }); + + // --------------------------------------------------------------------------- + // Render — initial state + // --------------------------------------------------------------------------- + + it("renders all trigger actions and initial idle status without errors", () => { + render( + + ); + + expect(screen.getByTestId("transaction-signer")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Sign Transaction" }) + ).toBeInTheDocument(); + expect( + screen.getByTestId("transaction-signer-status") + ).toHaveTextContent("idle"); + }); + + // --------------------------------------------------------------------------- + // Network mismatch — no warning + // --------------------------------------------------------------------------- + + it("shows no network warning when networks match", () => { + render( + + ); + + expect( + screen.queryByTestId("transaction-signer-network-warning-bar") + ).not.toBeInTheDocument(); + }); + + // --------------------------------------------------------------------------- + // Network mismatch — warning bar + // --------------------------------------------------------------------------- + + it("shows network warning when networks mismatch", () => { + render( + + ); + + const bar = screen.getByTestId("transaction-signer-network-warning-bar"); + expect(bar).toBeInTheDocument(); + expect(bar).toHaveAttribute("role", "alert"); + expect(bar).toHaveTextContent(/Network mismatch/i); + }); + + it("disables the sign button when networks mismatch", () => { + render( + + ); + + const signButton = screen.getByRole("button", { + name: "Sign Transaction", + }); + expect(signButton).toBeDisabled(); + }); + + it("does not call signTransaction when networks mismatch", async () => { + const signTransaction = vi.fn(); + + render( + + ); + + const signButton = screen.getByRole("button", { + name: "Sign Transaction", + }); + // The button is disabled when networks mismatch, so the browser + // does not fire the click handler. Verify signTransaction is never + // called and the button is disabled. + expect(signButton).toBeDisabled(); + fireEvent.click(signButton); + + expect(signTransaction).not.toHaveBeenCalled(); + }); + + it("shows the warning bar content when networks mismatch (console warn tested in unit tests)", () => { + render( + + ); + + const bar = screen.getByRole("alert"); + expect(bar).toHaveTextContent(/Switch networks to continue/i); + }); + + // --------------------------------------------------------------------------- + // Signing — happy path + // --------------------------------------------------------------------------- + + it("transitions to signed status when signing succeeds", async () => { + const signTransaction = vi.fn().mockResolvedValue("signed-xdr-abc"); + const onSigned = vi.fn(); + + render( + + ); + + fireEvent.click(screen.getByRole("button", { name: "Sign Transaction" })); + + expect( + screen.getByTestId("transaction-signer-status") + ).toHaveTextContent("signing"); + + await waitFor(() => { + expect( + screen.getByTestId("transaction-signer-status") + ).toHaveTextContent("signed"); + }); + + expect(signTransaction).toHaveBeenCalledTimes(1); + expect(onSigned).toHaveBeenCalledWith("signed-xdr-abc"); + }); + + // --------------------------------------------------------------------------- + // Signing — user rejection + // --------------------------------------------------------------------------- + + it("transitions to rejected status when user declines", async () => { + const signTransaction = vi + .fn() + .mockRejectedValue(new Error("User rejected transaction")); + + render( + + ); + + fireEvent.click(screen.getByRole("button", { name: "Sign Transaction" })); + + await waitFor(() => { + expect( + screen.getByTestId("transaction-signer-status") + ).toHaveTextContent("rejected"); + }); + }); + + it("also handles 'user declined' rejection phrasing", async () => { + const signTransaction = vi + .fn() + .mockRejectedValue(new Error("User declined the request")); + + render( + + ); + + fireEvent.click(screen.getByRole("button", { name: "Sign Transaction" })); + + await waitFor(() => { + expect( + screen.getByTestId("transaction-signer-status") + ).toHaveTextContent("rejected"); + }); + }); + + it("transitions to rejected status when signTransaction returns empty string", async () => { + const signTransaction = vi.fn().mockResolvedValue(""); + + render( + + ); + + fireEvent.click(screen.getByRole("button", { name: "Sign Transaction" })); + + await waitFor(() => { + expect( + screen.getByTestId("transaction-signer-status") + ).toHaveTextContent("rejected"); + }); + }); + + // --------------------------------------------------------------------------- + // Signing — unexpected error + // --------------------------------------------------------------------------- + + it("transitions to error status for unexpected signing failures", async () => { + const signTransaction = vi + .fn() + .mockRejectedValue(new Error("Extension crashed")); + + render( + + ); + + fireEvent.click(screen.getByRole("button", { name: "Sign Transaction" })); + + await waitFor(() => { + expect( + screen.getByTestId("transaction-signer-status") + ).toHaveTextContent("error"); + }); + + expect(warnSpy).toHaveBeenCalled(); + const logged = String(warnSpy.mock.calls[0][0]); + expect(logged).toContain("[transaction_signer]"); + expect(logged).toContain("SIGN ERROR"); + }); + + // --------------------------------------------------------------------------- + // Children rendering + // --------------------------------------------------------------------------- + + it("renders children inside the component", () => { + render( + +
Custom content
+
+ ); + + expect(screen.getByTestId("child-content")).toBeInTheDocument(); + expect(screen.getByTestId("child-content")).toHaveTextContent( + "Custom content" + ); + }); +}); diff --git a/__tests__/transaction_signer.integration.test.tsx b/__tests__/transaction_signer.integration.test.tsx new file mode 100644 index 0000000..d06fa86 --- /dev/null +++ b/__tests__/transaction_signer.integration.test.tsx @@ -0,0 +1,305 @@ +import { render, screen, act, fireEvent, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { WalletProvider, useWallet } from "@/app/context/WalletContext"; +import { ToastProvider } from "@/app/context/ToastContext"; +import TransactionSigner from "@/app/components/TransactionSigner"; +import type { TransactionSignerNetwork } from "@/app/lib/transaction_signer"; + +// --------------------------------------------------------------------------- +// Mocks — mirror wallet_state_context.test.tsx +// --------------------------------------------------------------------------- + +const kitState = { + getAddress: vi.fn(), + authModal: vi.fn(), + getNetwork: vi.fn(), + signTransaction: vi.fn(), + disconnect: vi.fn(), + init: vi.fn(), + setWallet: vi.fn(), +}; + +vi.mock("@creit.tech/stellar-wallets-kit", () => ({ + Networks: { TESTNET: "Test SDF Network ; September 2015" }, + StellarWalletsKit: { + init: (...args: unknown[]) => kitState.init(...args), + getAddress: (...args: unknown[]) => kitState.getAddress(...args), + authModal: (...args: unknown[]) => kitState.authModal(...args), + getNetwork: (...args: unknown[]) => kitState.getNetwork(...args), + signTransaction: (...args: unknown[]) => kitState.signTransaction(...args), + disconnect: (...args: unknown[]) => kitState.disconnect(...args), + setWallet: (...args: unknown[]) => kitState.setWallet(...args), + }, +})); + +vi.mock("@creit.tech/stellar-wallets-kit/modules/utils", () => ({ + defaultModules: vi.fn(() => []), +})); + +vi.mock("@/app/lib/freighter_connector", () => ({ + freighterActiveAddress: { + setActiveAddress: vi.fn(), + clear: vi.fn(), + }, + verifyAndRehydrateFreighterAddress: vi.fn(async () => null), +})); + +vi.mock("@/app/lib/ledger_usb_bridge", () => ({ + ledgerActiveAddresses: { + clear: vi.fn(), + }, +})); + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const TESTNET_PASSPHRASE = "Test SDF Network ; September 2015"; +const MAINNET_PASSPHRASE = "Public Global Stellar Network ; September 2015"; + +// --------------------------------------------------------------------------- +// Harness — bridges WalletContext → TransactionSigner +// --------------------------------------------------------------------------- + +/** + * A test harness that: + * 1. Uses the real WalletProvider to get wallet state + * 2. Exposes a Connect button so tests can trigger the flow + * 3. When connected, renders TransactionSigner with the wallet's network info + */ +function TransactionSignerHarness({ + walletNetwork = "testnet", +}: { + walletNetwork?: TransactionSignerNetwork; +}) { + const { + address, + connect, + disconnect, + networkMismatchMessage, + signTransaction, + } = useWallet(); + + // The app network is always testnet (matches NETWORK_PASSPHRASE default) + const appNetwork: TransactionSignerNetwork = "testnet"; + + // walletNetwork is passed directly by the caller. + + return ( +
+ {address ? ( + <> + {address} + {networkMismatchMessage && ( +
+ {networkMismatchMessage} +
+ )} + signTransaction("fake-xdr")} + /> + + + ) : ( + + )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Integration tests +// --------------------------------------------------------------------------- + +describe("TransactionSigner + WalletContext integration (#216)", () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + kitState.getNetwork.mockResolvedValue({ + networkPassphrase: TESTNET_PASSPHRASE, + }); + }); + + afterEach(() => { + localStorage.clear(); + }); + + // ------------------------------------------------------------------------- + // Network match — no warning + // ------------------------------------------------------------------------- + + it("shows no warning bar when wallet and app are both on testnet", async () => { + kitState.authModal.mockResolvedValue({ address: "GTESTNET123456" }); + + render( + + + + + + ); + + // Connect + await act(async () => { + screen.getByText("connect").click(); + }); + + await waitFor(() => { + expect(screen.getByTestId("connected-address")).toHaveTextContent( + "GTESTNET123456" + ); + }); + + // TransactionSigner should be visible with no warning bar + expect(screen.getByTestId("transaction-signer")).toBeInTheDocument(); + expect( + screen.queryByTestId("transaction-signer-network-warning-bar") + ).not.toBeInTheDocument(); + }); + + // ------------------------------------------------------------------------- + // Network mismatch — warning bar visible + // ------------------------------------------------------------------------- + + it("shows warning bar when wallet is on mainnet but app expects testnet", async () => { + kitState.authModal.mockResolvedValue({ address: "GMAINNET123456" }); + + render( + + + + + + ); + + // Connect + await act(async () => { + screen.getByText("connect").click(); + }); + + await waitFor(() => { + expect(screen.getByTestId("connected-address")).toHaveTextContent( + "GMAINNET123456" + ); + }); + + // TransactionSigner shows the warning bar + const bar = screen.getByTestId("transaction-signer-network-warning-bar"); + expect(bar).toBeInTheDocument(); + expect(bar).toHaveAttribute("role", "alert"); + expect(bar).toHaveTextContent(/Network mismatch/i); + }); + + // ------------------------------------------------------------------------- + // Network mismatch — sign button disabled + // ------------------------------------------------------------------------- + + it("disables the Sign Transaction button on network mismatch", async () => { + kitState.authModal.mockResolvedValue({ address: "GMAINNET123456" }); + + render( + + + + + + ); + + await act(async () => { + screen.getByText("connect").click(); + }); + + await waitFor(() => { + expect(screen.getByTestId("connected-address")).toBeInTheDocument(); + }); + + const signButton = screen.getByRole("button", { + name: "Sign Transaction", + }); + expect(signButton).toBeDisabled(); + }); + + // ------------------------------------------------------------------------- + // Sign flow — successful signing on matching network + // ------------------------------------------------------------------------- + + it("allows signing when networks match and transitions to signed status", async () => { + kitState.authModal.mockResolvedValue({ address: "GTESTNET123456" }); + kitState.signTransaction.mockResolvedValue({ signedTxXdr: "signed-xdr" }); + + render( + + + + + + ); + + // Connect + await act(async () => { + screen.getByText("connect").click(); + }); + + await waitFor(() => { + expect(screen.getByTestId("transaction-signer")).toBeInTheDocument(); + }); + + // No warning bar when networks match + expect( + screen.queryByTestId("transaction-signer-network-warning-bar") + ).not.toBeInTheDocument(); + + // Sign — click and wait for status transition + fireEvent.click(screen.getByRole("button", { name: "Sign Transaction" })); + + await waitFor(() => { + expect( + screen.getByTestId("transaction-signer-status") + ).toHaveTextContent("signed"); + }); + }); + + // ------------------------------------------------------------------------- + // Disconnect clears state + // ------------------------------------------------------------------------- + + it("disconnect clears the wallet and hides the TransactionSigner", async () => { + kitState.authModal.mockResolvedValue({ address: "GTESTNET123456" }); + kitState.disconnect.mockResolvedValue(undefined); + + render( + + + + + + ); + + // Connect + await act(async () => { + screen.getByText("connect").click(); + }); + + await waitFor(() => { + expect(screen.getByTestId("transaction-signer")).toBeInTheDocument(); + }); + + // Disconnect + await act(async () => { + screen.getByText("disconnect").click(); + }); + + await waitFor(() => { + expect(screen.queryByTestId("transaction-signer")).not.toBeInTheDocument(); + }); + + // The connect button should be back + expect(screen.getByText("connect")).toBeInTheDocument(); + }); +}); diff --git a/__tests__/transaction_signer_network_mismatch.test.ts b/__tests__/transaction_signer_network_mismatch.test.ts new file mode 100644 index 0000000..16d05df --- /dev/null +++ b/__tests__/transaction_signer_network_mismatch.test.ts @@ -0,0 +1,106 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + checkTransactionSignerNetworkMatch, + warnOnTransactionSignerNetworkMismatch, + TransactionSignerNetworkMismatchError, +} from "@/app/lib/transaction_signer"; + +describe("transaction_signer checkTransactionSignerNetworkMatch (#216)", () => { + it("reports no mismatch when both networks are testnet", () => { + const state = checkTransactionSignerNetworkMatch("testnet", "testnet"); + expect(state.mismatched).toBe(false); + expect(state.warningMessage).toBeNull(); + expect(state.walletNetwork).toBe("testnet"); + expect(state.appNetwork).toBe("testnet"); + }); + + it("reports no mismatch when both networks are mainnet", () => { + const state = checkTransactionSignerNetworkMatch("mainnet", "mainnet"); + expect(state.mismatched).toBe(false); + expect(state.warningMessage).toBeNull(); + }); + + it("reports a mismatch when wallet is mainnet and app is testnet", () => { + const state = checkTransactionSignerNetworkMatch("mainnet", "testnet"); + expect(state.mismatched).toBe(true); + expect(state.warningMessage).toContain("Network mismatch"); + expect(state.warningMessage).toContain("Mainnet"); + expect(state.warningMessage).toContain("Testnet"); + }); + + it("reports a mismatch when wallet is testnet and app is mainnet", () => { + const state = checkTransactionSignerNetworkMatch("testnet", "mainnet"); + expect(state.mismatched).toBe(true); + expect(state.warningMessage).toContain("Network mismatch"); + expect(state.warningMessage).toContain("Testnet"); + expect(state.warningMessage).toContain("Mainnet"); + }); + + it("preserves both network values in the returned state", () => { + const state = checkTransactionSignerNetworkMatch("mainnet", "testnet"); + expect(state.walletNetwork).toBe("mainnet"); + expect(state.appNetwork).toBe("testnet"); + }); +}); + +describe("warnOnTransactionSignerNetworkMismatch (#216)", () => { + let warnSpy: ReturnType; + + afterEach(() => { + warnSpy.mockRestore(); + }); + + it("logs a warning to the console when networks mismatch", () => { + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const state = warnOnTransactionSignerNetworkMismatch("mainnet", "testnet"); + expect(state.mismatched).toBe(true); + expect(warnSpy).toHaveBeenCalledTimes(1); + const logged = String(warnSpy.mock.calls[0][0]); + expect(logged).toContain("[transaction_signer]"); + expect(logged).toContain("Network mismatch"); + }); + + it("does not log when networks match", () => { + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const state = warnOnTransactionSignerNetworkMismatch("testnet", "testnet"); + expect(state.mismatched).toBe(false); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it("returns the same state as checkTransactionSignerNetworkMatch", () => { + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const warnState = warnOnTransactionSignerNetworkMismatch( + "mainnet", + "testnet" + ); + const checkState = checkTransactionSignerNetworkMatch( + "mainnet", + "testnet" + ); + expect(warnState.mismatched).toBe(checkState.mismatched); + expect(warnState.warningMessage).toBe(checkState.warningMessage); + expect(warnState.walletNetwork).toBe(checkState.walletNetwork); + expect(warnState.appNetwork).toBe(checkState.appNetwork); + }); +}); + +describe("TransactionSignerNetworkMismatchError (#216)", () => { + it("has the correct name and message", () => { + const err = new TransactionSignerNetworkMismatchError( + "mainnet", + "testnet" + ); + expect(err.name).toBe("TransactionSignerNetworkMismatchError"); + expect(err.message).toContain("Network mismatch"); + expect(err.walletNetwork).toBe("mainnet"); + expect(err.appNetwork).toBe("testnet"); + }); + + it("is an instance of Error", () => { + const err = new TransactionSignerNetworkMismatchError( + "testnet", + "mainnet" + ); + expect(err).toBeInstanceOf(Error); + }); +}); diff --git a/__tests__/transaction_signer_network_warning_bar.test.tsx b/__tests__/transaction_signer_network_warning_bar.test.tsx new file mode 100644 index 0000000..148051f --- /dev/null +++ b/__tests__/transaction_signer_network_warning_bar.test.tsx @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import TransactionSignerNetworkWarningBar from "@/app/components/TransactionSignerNetworkWarningBar"; + +describe("TransactionSignerNetworkWarningBar (#216)", () => { + it("renders a warning bar when the wallet network does not match the app network", () => { + render( + + ); + + const bar = screen.getByTestId("transaction-signer-network-warning-bar"); + expect(bar).toBeInTheDocument(); + expect(bar).toHaveAttribute("role", "alert"); + expect(bar).toHaveTextContent(/Network mismatch/i); + expect(bar).toHaveTextContent(/Mainnet/); + expect(bar).toHaveTextContent(/Testnet/); + }); + + it("does not render when the wallet network matches the app network", () => { + const { container } = render( + + ); + + expect( + screen.queryByTestId("transaction-signer-network-warning-bar") + ).not.toBeInTheDocument(); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders the reverse mismatch (testnet wallet, mainnet app)", () => { + render( + + ); + + const bar = screen.getByTestId("transaction-signer-network-warning-bar"); + expect(bar).toBeInTheDocument(); + expect(bar).toHaveTextContent(/Network mismatch/i); + expect(bar).toHaveTextContent(/Testnet/); + expect(bar).toHaveTextContent(/Mainnet/); + }); + + it("applies additional className when provided", () => { + render( + + ); + + const bar = screen.getByTestId("transaction-signer-network-warning-bar"); + expect(bar.className).toContain("mt-4"); + }); + + it("uses role=alert for accessibility", () => { + render( + + ); + + const bar = screen.getByRole("alert"); + expect(bar).toHaveTextContent(/Network mismatch/i); + }); +}); diff --git a/app/components/TransactionSigner.tsx b/app/components/TransactionSigner.tsx new file mode 100644 index 0000000..7c04a4e --- /dev/null +++ b/app/components/TransactionSigner.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { + checkTransactionSignerNetworkMatch, + warnOnTransactionSignerNetworkMismatch, + type TransactionSignerNetwork, +} from "@/app/lib/transaction_signer"; +import TransactionSignerNetworkWarningBar from "@/app/components/TransactionSignerNetworkWarningBar"; + +export type TransactionSignerStatus = + | "idle" + | "signing" + | "signed" + | "rejected" + | "error"; + +export interface TransactionSignerProps { + /** The wallet's current network. */ + walletNetwork: TransactionSignerNetwork; + /** The network the app expects. */ + appNetwork: TransactionSignerNetwork; + /** Called to sign the transaction XDR. */ + signTransaction: () => Promise; + /** Called with the signed XDR after a successful signing. */ + onSigned?: (signedXdr: string) => void; + /** Optional transaction identifier for logging. */ + txId?: string; + children?: React.ReactNode; +} + +/** + * Unified transaction signing interface with integrated chain network + * mismatch detection. Displays a warning bar when the wallet network + * does not match the app network, and manages the signing lifecycle. + */ +export default function TransactionSigner({ + walletNetwork, + appNetwork, + signTransaction, + onSigned, + txId = "tx-signer", + children, +}: TransactionSignerProps) { + const [status, setStatus] = useState("idle"); + + const networkState = checkTransactionSignerNetworkMatch( + walletNetwork, + appNetwork + ); + + const handleSign = useCallback(async () => { + if (networkState.mismatched) { + warnOnTransactionSignerNetworkMismatch(walletNetwork, appNetwork); + return; + } + + setStatus("signing"); + + try { + const signedXdr = await signTransaction(); + if (signedXdr) { + onSigned?.(signedXdr); + setStatus("signed"); + } else { + setStatus("rejected"); + } + } catch (err) { + const message = err instanceof Error ? err.message : "Signing failed."; + const lc = message.toLowerCase(); + const isRejection = + lc.includes("user rejected") || + lc.includes("user declined") || + lc.includes("request rejected") || + lc.includes("cancelled") || + lc.includes("canceled"); + + console.warn( + `[transaction_signer] ${isRejection ? "SIGNATURE REJECTED" : "SIGN ERROR"}: ${message}` + ); + + setStatus(isRejection ? "rejected" : "error"); + } + }, [ + networkState.mismatched, + walletNetwork, + appNetwork, + signTransaction, + onSigned, + ]); + + return ( +
+ + {children} + + {status} + {txId && ( + + {txId} + + )} +
+ ); +} diff --git a/app/components/TransactionSignerNetworkWarningBar.tsx b/app/components/TransactionSignerNetworkWarningBar.tsx new file mode 100644 index 0000000..a75d08f --- /dev/null +++ b/app/components/TransactionSignerNetworkWarningBar.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { + checkTransactionSignerNetworkMatch, + type TransactionSignerNetwork, +} from "@/app/lib/transaction_signer"; + +interface Props { + walletNetwork: TransactionSignerNetwork; + appNetwork: TransactionSignerNetwork; + className?: string; +} + +/** + * Warning bar rendered by transaction_signer network checks when the + * connected wallet chain does not match the app network. + */ +export default function TransactionSignerNetworkWarningBar({ + walletNetwork, + appNetwork, + className = "", +}: Props) { + const state = checkTransactionSignerNetworkMatch(walletNetwork, appNetwork); + + if (!state.mismatched || !state.warningMessage) { + return null; + } + + return ( +
+ {state.warningMessage} +
+ ); +} diff --git a/app/lib/transaction_signer.ts b/app/lib/transaction_signer.ts new file mode 100644 index 0000000..39ffad8 --- /dev/null +++ b/app/lib/transaction_signer.ts @@ -0,0 +1,69 @@ +/** + * transaction_signer — Unified transaction signing interface with + * chain network mismatch detection (#216). + */ + +// --------------------------------------------------------------------------- +// Network mismatch checks +// --------------------------------------------------------------------------- + +/** Chains the Stellar wallet can be pointed at. */ +export type TransactionSignerNetwork = "mainnet" | "testnet"; + +export interface TransactionSignerNetworkMismatchState { + mismatched: boolean; + walletNetwork: TransactionSignerNetwork; + appNetwork: TransactionSignerNetwork; + warningMessage: string | null; +} + +export class TransactionSignerNetworkMismatchError extends Error { + constructor( + public readonly walletNetwork: TransactionSignerNetwork, + public readonly appNetwork: TransactionSignerNetwork + ) { + super( + `Network mismatch: wallet is on ${walletNetwork}, app expects ${appNetwork}` + ); + this.name = "TransactionSignerNetworkMismatchError"; + } +} + +function capitalizeNetworkName(value: string): string { + return value.charAt(0).toUpperCase() + value.slice(1); +} + +/** + * Compares the network the wallet is pointed at against the network the app + * expects and produces a user-facing warning message when they diverge + * (e.g. Mainnet vs Testnet). + */ +export function checkTransactionSignerNetworkMatch( + walletNetwork: TransactionSignerNetwork, + appNetwork: TransactionSignerNetwork +): TransactionSignerNetworkMismatchState { + const mismatched = walletNetwork !== appNetwork; + return { + mismatched, + walletNetwork, + appNetwork, + warningMessage: mismatched + ? `Network mismatch: your wallet is on ${capitalizeNetworkName(walletNetwork)} but this app uses ${capitalizeNetworkName(appNetwork)}. Switch networks to continue.` + : null, + }; +} + +/** + * Runs a network match check and, on mismatch, logs a warning to the console + * via the shared transaction_signer debug conventions. + */ +export function warnOnTransactionSignerNetworkMismatch( + walletNetwork: TransactionSignerNetwork, + appNetwork: TransactionSignerNetwork +): TransactionSignerNetworkMismatchState { + const state = checkTransactionSignerNetworkMatch(walletNetwork, appNetwork); + if (state.mismatched && state.warningMessage) { + console.warn(`[transaction_signer] ${state.warningMessage}`); + } + return state; +}