From 2d868f21ac3794615554a5c19a4e1c8913b46db4 Mon Sep 17 00:00:00 2001 From: Bruno Menezes Date: Mon, 2 Nov 2026 14:30:23 +0000 Subject: [PATCH 1/6] feat(wallet): Add a wallet debug overlay to development builds. --- README.md | 9 + .../wallet/debug/WalletDebugPanel.test.tsx | 196 ++++++++++++++ .../wallet/debug/walletDebugFlag.test.ts | 94 +++++++ .../wallet/debug/walletDebugLog.test.ts | 95 +++++++ next.config.js | 5 + src/components/Web3Container.tsx | 17 ++ .../wallet/debug/WalletDebugPanel.tsx | 244 ++++++++++++++++++ src/components/wallet/debug/index.ts | 17 ++ .../wallet/debug/walletDebugFlag.ts | 61 +++++ src/components/wallet/debug/walletDebugLog.ts | 89 +++++++ 10 files changed, 827 insertions(+) create mode 100644 __tests__/components/wallet/debug/WalletDebugPanel.test.tsx create mode 100644 __tests__/components/wallet/debug/walletDebugFlag.test.ts create mode 100644 __tests__/components/wallet/debug/walletDebugLog.test.ts create mode 100644 src/components/wallet/debug/WalletDebugPanel.tsx create mode 100644 src/components/wallet/debug/index.ts create mode 100644 src/components/wallet/debug/walletDebugFlag.ts create mode 100644 src/components/wallet/debug/walletDebugLog.ts diff --git a/README.md b/README.md index 62a193f1..deb3ee3f 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,15 @@ Swapping the wallet library again means changing that folder only. - RainbowKit 2 pins wagmi to the 2.x line, which froze when wagmi 3 shipped. The bundled WalletConnect and MetaMask SDK versions move only when RainbowKit adds wagmi 3 support; this is a known, accepted trade-off. +- In development builds (`next dev`; production bundles carry none of its code), + append `?walletDebug=1` to any explorer URL to open a diagnostics overlay + (`src/components/wallet/debug/`): wagmi status, the chain wagmi recorded + against the chain the connector and the injected provider report, the wallet + client error, RainbowKit modal state, injected provider flags and recent + console errors, as a copyable JSON snapshot, plus buttons that ask the wallet + to switch chain. Made for wallet in-app browsers, which have no devtools. + Minimize collapses it to a corner pill that brings it back; the flag and the + minimized state are remembered until `?walletDebug=0` or the Hide button. Contracts are called through [viem](https://viem.sh) using ABIs and deployment addresses generated into `src/services/contracts/generated.ts` (gitignored) by diff --git a/__tests__/components/wallet/debug/WalletDebugPanel.test.tsx b/__tests__/components/wallet/debug/WalletDebugPanel.test.tsx new file mode 100644 index 00000000..9989264d --- /dev/null +++ b/__tests__/components/wallet/debug/WalletDebugPanel.test.tsx @@ -0,0 +1,196 @@ +// Copyright (C) 2026 Cartesi Pte. Ltd. + +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU General Public License as published by the Free Software +// Foundation, either version 3 of the License, or (at your option) any later +// version. + +// This program is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A +// PARTICULAR PURPOSE. See the GNU General Public License for more details. + +import { useConnectModal } from '@rainbow-me/rainbowkit'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { useAccount, useChainId, useConfig, useSwitchChain } from 'wagmi'; +import WalletDebugPanel from '../../../../src/components/wallet/debug/WalletDebugPanel'; +import { + WALLET_DEBUG_MINIMIZED_KEY, + WALLET_DEBUG_STORAGE_KEY, +} from '../../../../src/components/wallet/debug/walletDebugFlag'; +import { useWallet } from '../../../../src/components/wallet/useWallet'; +import { withChakraTheme } from '../../../test-utilities'; + +jest.mock('wagmi', () => ({ + useAccount: jest.fn(), + useChainId: jest.fn(), + useConfig: jest.fn(), + useSwitchChain: jest.fn(), +})); + +jest.mock('@rainbow-me/rainbowkit', () => ({ + useConnectModal: jest.fn(), +})); + +jest.mock('../../../../src/components/wallet/useWallet', () => ({ + useWallet: jest.fn(), +})); + +const Panel = withChakraTheme(WalletDebugPanel); +const address = '0x907eA0e65Ecf3af503007B382E1280Aeb46104ad'; +const connector = { + id: 'baseAccount', + name: 'Base Account', + type: 'baseAccount', + getChainId: jest.fn().mockResolvedValue(8453), +}; +const mismatch = Object.assign(new Error('chain 8453 vs 1'), { + name: 'ConnectorChainMismatchError', +}); +const switchChainAsync = jest.fn(); + +const readSnapshot = () => + JSON.parse( + (screen.getByRole('textbox') as HTMLTextAreaElement).value || '{}', + ); +const waitForProbes = () => + waitFor(() => expect(readSnapshot().probes.connectorChainId).toBe('8453')); + +describe('WalletDebugPanel', () => { + beforeEach(() => { + window.localStorage.setItem(WALLET_DEBUG_STORAGE_KEY, '1'); + window.ethereum = { + isCoinbaseWallet: true, + isCoinbaseBrowser: true, + request: jest.fn().mockResolvedValue('0x2105'), + }; + (useAccount as jest.Mock).mockReturnValue({ + status: 'connected', + address, + chainId: 1, + connector, + }); + (useChainId as jest.Mock).mockReturnValue(1); + (useConfig as jest.Mock).mockReturnValue({ + connectors: [{ id: 'metaMaskSDK' }, { id: 'baseAccount' }], + }); + (useSwitchChain as jest.Mock).mockReturnValue({ switchChainAsync }); + (useConnectModal as jest.Mock).mockReturnValue({ + openConnectModal: undefined, + connectModalOpen: false, + }); + (useWallet as jest.Mock).mockReturnValue({ + active: false, + walletClientError: mismatch, + }); + switchChainAsync.mockResolvedValue({ id: 11155111 }); + }); + + afterEach(() => { + window.localStorage.clear(); + delete (window as { ethereum?: unknown }).ethereum; + jest.clearAllMocks(); + }); + + it('renders nothing unless the flag is set', () => { + window.localStorage.clear(); + + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); + + it('shows a snapshot that tells the recorded chain from the wallet chain', async () => { + render(); + + await waitForProbes(); + expect(readSnapshot()).toMatchObject({ + app: { + active: false, + walletClientError: expect.stringContaining( + 'ConnectorChainMismatchError', + ), + }, + wagmi: { + status: 'connected', + chainId: 1, + configChainId: 1, + connector: { id: 'baseAccount' }, + connectorIds: ['metaMaskSDK', 'baseAccount'], + }, + probes: { connectorChainId: '8453', injectedChainId: '0x2105' }, + rainbowKit: { openConnectModalDefined: false }, + injected: { present: true, isCoinbaseBrowser: true }, + }); + }); + + it('asks the wallet to switch to Sepolia and reports the answer', async () => { + render(); + + fireEvent.click(screen.getByText('Switch to Sepolia')); + + expect(switchChainAsync).toHaveBeenCalledWith({ chainId: 11155111 }); + await waitFor(() => + expect(readSnapshot().switchResult).toBe('switched to 11155111'), + ); + }); + + it('reports a refused switch', async () => { + switchChainAsync.mockRejectedValue( + Object.assign(new Error('User rejected'), { code: 4001 }), + ); + render(); + + fireEvent.click(screen.getByText('Switch to Mainnet')); + + await waitFor(() => + expect(readSnapshot().switchResult).toBe( + 'switch to 1 failed: Error: User rejected (code 4001)', + ), + ); + }); + + it('copies the snapshot to the clipboard', async () => { + const writeText = jest.fn().mockResolvedValue(undefined); + Object.assign(navigator, { clipboard: { writeText } }); + render(); + await waitForProbes(); + + fireEvent.click(screen.getByText('Copy JSON')); + + expect(writeText).toHaveBeenCalledWith( + expect.stringContaining('"connectorChainId": "8453"'), + ); + await screen.findByText('copied'); + }); + + it('minimizes to a pill that brings it back', async () => { + render(); + await waitForProbes(); + + fireEvent.click(screen.getByText('Minimize')); + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('wallet-debug-pill')); + expect(readSnapshot().probes.connectorChainId).toBe('8453'); + }); + + it('starts minimized when it was left that way', () => { + window.localStorage.setItem(WALLET_DEBUG_MINIMIZED_KEY, '1'); + + render(); + + expect(screen.getByTestId('wallet-debug-pill')).toBeInTheDocument(); + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + }); + + it('hides itself and forgets the flag', () => { + const { container } = render(); + + fireEvent.click(screen.getByText('Hide')); + + expect(container).toBeEmptyDOMElement(); + expect( + window.localStorage.getItem(WALLET_DEBUG_STORAGE_KEY), + ).toBeNull(); + }); +}); diff --git a/__tests__/components/wallet/debug/walletDebugFlag.test.ts b/__tests__/components/wallet/debug/walletDebugFlag.test.ts new file mode 100644 index 00000000..54d9c141 --- /dev/null +++ b/__tests__/components/wallet/debug/walletDebugFlag.test.ts @@ -0,0 +1,94 @@ +// Copyright (C) 2026 Cartesi Pte. Ltd. + +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU General Public License as published by the Free Software +// Foundation, either version 3 of the License, or (at your option) any later +// version. + +// This program is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A +// PARTICULAR PURPOSE. See the GNU General Public License for more details. + +import { + clearWalletDebugFlag, + isWalletDebugEnabled, + isWalletDebugMinimized, + setWalletDebugMinimized, + WALLET_DEBUG_STORAGE_KEY, +} from '../../../../src/components/wallet/debug/walletDebugFlag'; + +const visit = (path: string) => window.history.replaceState(null, '', path); +const nodeEnv = process.env.NODE_ENV; + +describe('wallet debug flag', () => { + afterEach(() => { + window.localStorage.clear(); + visit('/'); + Object.assign(process.env, { NODE_ENV: nodeEnv }); + jest.restoreAllMocks(); + }); + + it('is off by default', () => { + expect(isWalletDebugEnabled()).toBe(false); + }); + + it('is never on in production builds', () => { + Object.assign(process.env, { NODE_ENV: 'production' }); + window.localStorage.setItem(WALLET_DEBUG_STORAGE_KEY, '1'); + + visit('/?walletDebug=1'); + + expect(isWalletDebugEnabled()).toBe(false); + }); + + it('turns on with ?walletDebug=1 and stays on without the query', () => { + visit('/?walletDebug=1'); + expect(isWalletDebugEnabled()).toBe(true); + + visit('/stake'); + expect(isWalletDebugEnabled()).toBe(true); + }); + + it('turns off with ?walletDebug=0', () => { + window.localStorage.setItem(WALLET_DEBUG_STORAGE_KEY, '1'); + + visit('/?walletDebug=0'); + + expect(isWalletDebugEnabled()).toBe(false); + visit('/'); + expect(isWalletDebugEnabled()).toBe(false); + }); + + it('is forgotten by clearWalletDebugFlag, along with the minimized state', () => { + window.localStorage.setItem(WALLET_DEBUG_STORAGE_KEY, '1'); + setWalletDebugMinimized(true); + + clearWalletDebugFlag(); + + expect(isWalletDebugEnabled()).toBe(false); + expect(isWalletDebugMinimized()).toBe(false); + }); + + it('remembers whether the overlay is minimized', () => { + expect(isWalletDebugMinimized()).toBe(false); + + setWalletDebugMinimized(true); + expect(isWalletDebugMinimized()).toBe(true); + + setWalletDebugMinimized(false); + expect(isWalletDebugMinimized()).toBe(false); + }); + + it('follows the query when storage is unavailable', () => { + const blocked = () => { + throw new Error('blocked'); + }; + jest.spyOn(Storage.prototype, 'getItem').mockImplementation(blocked); + jest.spyOn(Storage.prototype, 'setItem').mockImplementation(blocked); + + visit('/?walletDebug=1'); + expect(isWalletDebugEnabled()).toBe(true); + visit('/'); + expect(isWalletDebugEnabled()).toBe(false); + }); +}); diff --git a/__tests__/components/wallet/debug/walletDebugLog.test.ts b/__tests__/components/wallet/debug/walletDebugLog.test.ts new file mode 100644 index 00000000..b0cc7b59 --- /dev/null +++ b/__tests__/components/wallet/debug/walletDebugLog.test.ts @@ -0,0 +1,95 @@ +// Copyright (C) 2026 Cartesi Pte. Ltd. + +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU General Public License as published by the Free Software +// Foundation, either version 3 of the License, or (at your option) any later +// version. + +// This program is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A +// PARTICULAR PURPOSE. See the GNU General Public License for more details. + +import { + describeError, + getWalletDebugEntries, + installWalletDebugCapture, +} from '../../../../src/components/wallet/debug/walletDebugLog'; + +const lastEntry = () => getWalletDebugEntries().at(-1); + +describe('describeError', () => { + it('formats errors with their name, message and code', () => { + expect(describeError(new Error('boom'))).toBe('Error: boom'); + expect( + describeError(Object.assign(new Error('nope'), { code: 4001 })), + ).toBe('Error: nope (code 4001)'); + expect(describeError('plain')).toBe('plain'); + }); +}); + +describe('wallet debug log', () => { + let errorSpy: jest.SpyInstance; + let warnSpy: jest.SpyInstance; + let uninstall: () => void; + + beforeEach(() => { + errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + uninstall = installWalletDebugCapture(); + }); + + afterEach(() => { + uninstall(); + jest.restoreAllMocks(); + }); + + it('captures console errors and warnings and still forwards them', () => { + console.error('boom', new Error('detail')); + console.warn('careful', { code: 4001 }); + + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(getWalletDebugEntries().slice(-2)).toMatchObject([ + { kind: 'console.error', message: 'boom Error: detail' }, + { kind: 'console.warn', message: 'careful {"code":4001}' }, + ]); + }); + + it('captures window errors and unhandled rejections', () => { + window.dispatchEvent( + new ErrorEvent('error', { message: 'window boom' }), + ); + expect(lastEntry()).toMatchObject({ + kind: 'window.error', + message: 'window boom', + }); + + const rejection = new Event('unhandledrejection'); + Object.assign(rejection, { reason: new Error('nope') }); + window.dispatchEvent(rejection); + expect(lastEntry()).toMatchObject({ + kind: 'unhandledrejection', + message: 'Error: nope', + }); + }); + + it('keeps the last twenty entries', () => { + for (let i = 0; i < 25; i++) console.warn(`entry ${i}`); + + const entries = getWalletDebugEntries(); + expect(entries).toHaveLength(20); + expect(entries[0].message).toBe('entry 5'); + expect(entries[19].message).toBe('entry 24'); + }); + + it('installs only once and restores the console on uninstall', () => { + expect(installWalletDebugCapture()).toBe(uninstall); + + uninstall(); + console.warn('after uninstall'); + + expect(lastEntry()?.message).not.toBe('after uninstall'); + expect(warnSpy).toHaveBeenCalledWith('after uninstall'); + uninstall = installWalletDebugCapture(); + }); +}); diff --git a/next.config.js b/next.config.js index e49fde56..8d682e2e 100644 --- a/next.config.js +++ b/next.config.js @@ -11,6 +11,11 @@ module.exports = { reactStrictMode: true, // Do not generate the AGENTS.md / CLAUDE.md files next writes on "next dev". agentRules: false, + // Next refuses dev-server requests from origins other than localhost. To + // open the dev server from a phone on the same network (for example a + // wallet's in-app browser at http://:3000/?walletDebug=1), run + // `pnpm next:dev -H 0.0.0.0` and allow-list the host here. + // allowedDevOrigins: [''], // cdp-sdk (via wagmi's Base connector) imports optional @x402/* peers that // are not installed; keep it out of the server bundle. serverExternalPackages: ['@coinbase/cdp-sdk'], diff --git a/src/components/Web3Container.tsx b/src/components/Web3Container.tsx index 7655c2b0..7bb05c41 100644 --- a/src/components/Web3Container.tsx +++ b/src/components/Web3Container.tsx @@ -17,12 +17,28 @@ import { RainbowKitProvider, } from '@rainbow-me/rainbowkit'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import dynamic from 'next/dynamic'; import { FC, ReactNode, useMemo, useState } from 'react'; import { type Config, WagmiProvider } from 'wagmi'; import theme from '../styles/theme'; import { useColorMode } from './ui/color-mode'; import { WalletConnectionProvider } from './wallet'; import { appMetadata, getWagmiConfig } from './wallet/config'; +import { + installWalletDebugCapture, + isWalletDebugEnabled, +} from './wallet/debug'; + +// Development builds only (the condition folds at build time, so production +// bundles carry no overlay code); loaded when ?walletDebug=1 is set. +const WalletDebugPanel = + process.env.NODE_ENV === 'production' + ? null + : dynamic(() => import('./wallet/debug/WalletDebugPanel'), { + ssr: false, + }); +// Capture errors thrown during reconnectOnMount, before the panel mounts. +if (WalletDebugPanel && isWalletDebugEnabled()) installWalletDebugCapture(); interface Web3ContainerProps { children: ReactNode; @@ -62,6 +78,7 @@ const Web3Container: FC = ({ children, config }) => { > {children} + {WalletDebugPanel && } diff --git a/src/components/wallet/debug/WalletDebugPanel.tsx b/src/components/wallet/debug/WalletDebugPanel.tsx new file mode 100644 index 00000000..8fa1a3ec --- /dev/null +++ b/src/components/wallet/debug/WalletDebugPanel.tsx @@ -0,0 +1,244 @@ +// Copyright (C) 2026 Cartesi Pte. Ltd. + +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU General Public License as published by the Free Software +// Foundation, either version 3 of the License, or (at your option) any later +// version. + +// This program is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A +// PARTICULAR PURPOSE. See the GNU General Public License for more details. + +'use client'; + +/** + * On-page wallet diagnostics for browsers without devtools, such as wallet + * in-app browsers. Development builds only. + * + * Shows a frozen, copyable JSON snapshot of the wagmi, RainbowKit and injected + * provider state (the chain wagmi recorded against the chain the connector and + * `window.ethereum` report, the wallet client error, whether RainbowKit would + * open its modal, recent console errors), with buttons that ask the wallet to + * switch chain. Minimize collapses it to a corner pill. + * + * @example + * // Turn it on (remembered for the session until turned off): + * // http://localhost:3000/?walletDebug=1 + * // Turn it off: the Hide button, or + * // http://localhost:3000/?walletDebug=0 + * // From a phone on the same network, run `pnpm next:dev -H 0.0.0.0`, allow + * // the host in next.config.js (`allowedDevOrigins`) and open + * // http://:3000/?walletDebug=1 + */ + +import { Box, Button, HStack, Text, Textarea } from '@chakra-ui/react'; +import { useConnectModal } from '@rainbow-me/rainbowkit'; +import { FC, useEffect, useEffectEvent, useState } from 'react'; +import { useAccount, useChainId, useConfig, useSwitchChain } from 'wagmi'; +import { Network } from '../../../utils/networks'; +import { useWallet } from '../useWallet'; +import { + clearWalletDebugFlag, + isWalletDebugEnabled, + isWalletDebugMinimized, + setWalletDebugMinimized, +} from './walletDebugFlag'; +import { describeError, getWalletDebugEntries } from './walletDebugLog'; + +/** The result of a request as a string, errors included. */ +const settle = async (request: () => Promise): Promise => { + try { + return String(await request()); + } catch (error) { + return `error: ${describeError(error)}`; + } +}; + +const readInjected = () => { + const injected = window.ethereum; + let topDiffersFromWindow: boolean | string; + try { + topDiffersFromWindow = window.top?.ethereum !== injected; + } catch (error) { + topDiffersFromWindow = describeError(error); + } + return { + present: !!injected, + isCoinbaseWallet: injected?.isCoinbaseWallet, + isCoinbaseBrowser: injected?.isCoinbaseBrowser, + isMetaMask: injected?.isMetaMask, + providersCount: injected?.providers?.length, + topDiffersFromWindow, + }; +}; + +const WalletDebugOverlay: FC<{ onHide: () => void }> = ({ onHide }) => { + const { address, chainId, connector, status } = useAccount(); + const configChainId = useChainId(); + const { connectors } = useConfig(); + const { openConnectModal, connectModalOpen } = useConnectModal(); + const { switchChainAsync } = useSwitchChain(); + const { active, error, walletClientError } = useWallet(); + const [switchResult, setSwitchResult] = useState(); + const [notice, setNotice] = useState(); + const [json, setJson] = useState(''); + // Remembered in storage, so a reload keeps the overlay out of the way. + const [minimized, setMinimized] = useState(isWalletDebugMinimized); + + // The JSON on screen is frozen, so it can be selected and copied; it is + // retaken on Refresh and on a wagmi state change, probing the chains then. + const takeSnapshot = async () => { + const injected = window.ethereum; + const [connectorChainId, injectedChainId] = await Promise.all([ + connector && settle(() => connector.getChainId()), + injected?.request && + settle(() => injected.request({ method: 'eth_chainId' })), + ]); + const snapshot = { + capturedAt: new Date().toISOString(), + app: { + active, + error: error && describeError(error), + walletClientError: + walletClientError && describeError(walletClientError), + }, + wagmi: { + status, + address, + chainId, + configChainId, + connector: connector && { + id: connector.id, + name: connector.name, + type: connector.type, + }, + connectorIds: connectors.map(({ id }) => id), + }, + probes: { connectorChainId, injectedChainId }, + rainbowKit: { + openConnectModalDefined: openConnectModal !== undefined, + connectModalOpen, + }, + injected: readInjected(), + env: { userAgent: navigator.userAgent, href: window.location.href }, + switchResult, + logs: getWalletDebugEntries(), + }; + setJson(JSON.stringify(snapshot, null, 2)); + }; + + const onStateChange = useEffectEvent(takeSnapshot); + useEffect(() => { + onStateChange(); + }, [status, chainId, connector, walletClientError, switchResult]); + + const minimize = (value: boolean) => { + setWalletDebugMinimized(value); + setMinimized(value); + }; + + const copy = () => { + if (!navigator.clipboard?.writeText) { + setNotice('no clipboard API: select the text box and copy'); + return; + } + navigator.clipboard + .writeText(json) + .then(() => setNotice('copied')) + .catch((err) => setNotice(`copy failed: ${describeError(err)}`)); + }; + + const switchTo = (target: Network) => { + setSwitchResult(`switching to ${target}...`); + switchChainAsync({ chainId: target }) + .then((chain) => setSwitchResult(`switched to ${chain.id}`)) + .catch((err) => + setSwitchResult( + `switch to ${target} failed: ${describeError(err)}`, + ), + ); + }; + + if (minimized) { + return ( + + ); + } + + return ( + + + Wallet debug + + + + + + + {notice && {notice}} + +