From 37510f4b5d6f07e2f83c1ec573a262d0ad4de8a5 Mon Sep 17 00:00:00 2001 From: DOUGLAS FRANCIS Date: Thu, 27 Aug 2026 07:12:24 -0700 Subject: [PATCH] feat: add network mismatch warnings to TransactionSigner component (#216) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build chain network checks in a new TransactionSigner component to alert users when their wallet network does not match the app (e.g. Mainnet vs Testnet). Includes network mismatch warning bar, sign button disabled on mismatch, and comprehensive unit/component/integration tests. 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- .../transaction_signer.component.test.tsx | 279 ++++++++++++++++ .../transaction_signer.integration.test.tsx | 305 ++++++++++++++++++ ...ransaction_signer_network_mismatch.test.ts | 106 ++++++ ...action_signer_network_warning_bar.test.tsx | 75 +++++ app/components/TransactionSigner.tsx | 115 +++++++ .../TransactionSignerNetworkWarningBar.tsx | 38 +++ app/lib/transaction_signer.ts | 69 ++++ 7 files changed, 987 insertions(+) create mode 100644 __tests__/transaction_signer.component.test.tsx create mode 100644 __tests__/transaction_signer.integration.test.tsx create mode 100644 __tests__/transaction_signer_network_mismatch.test.ts create mode 100644 __tests__/transaction_signer_network_warning_bar.test.tsx create mode 100644 app/components/TransactionSigner.tsx create mode 100644 app/components/TransactionSignerNetworkWarningBar.tsx create mode 100644 app/lib/transaction_signer.ts 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; +}