diff --git a/CHANGELOG.md b/CHANGELOG.md index ab1e2a66..98eddac6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [3.13.2] - 2026-11-02 + +- Fix connecting from the Coinbase Wallet in-app browser: wagmi's Base Account connector reported the chain it asked for instead of the chain the wallet was on, which left a connection the explorer could not use and a "Connect To Wallet" button that did nothing. The connector now checks the wallet's chain after connecting and switches to the requested one when they differ. +- Request the wallet client for the chain the wallet is on, so switching networks in the wallet takes effect without reloading the page. +- Add a wallet diagnostics overlay to development builds, for browsers without devtools such as wallet in-app browsers. + ## [3.13.1] - 2026-10-27 - Upgrade Next.js to 16.4.2 to patch the remote code execution, SSRF and cache-poisoning advisories in 16.3.x. @@ -504,7 +510,8 @@ Staking Pools - First release -[unreleased]: https://github.com/cartesi/explorer/compare/v3.13.1...HEAD +[unreleased]: https://github.com/cartesi/explorer/compare/v3.13.2...HEAD +[3.13.2]: https://github.com/cartesi/explorer/compare/v3.13.1...v3.13.2 [3.13.1]: https://github.com/cartesi/explorer/compare/v3.13.0...v3.13.1 [3.13.0]: https://github.com/cartesi/explorer/compare/v3.12.8...v3.13.0 [3.12.8]: https://github.com/cartesi/explorer/compare/v3.12.7...v3.12.8 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/baseAccountChainMismatch.test.ts b/__tests__/components/wallet/baseAccountChainMismatch.test.ts new file mode 100644 index 00000000..f0f95151 --- /dev/null +++ b/__tests__/components/wallet/baseAccountChainMismatch.test.ts @@ -0,0 +1,241 @@ +// 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. + +/** + * The Coinbase Wallet in-app browser injects `window.ethereum` flagged with + * `isCoinbaseBrowser`, which `@base-org/account` uses instead of its popup. + * wagmi's `baseAccount` connector then trusts the first chain in the + * `wallet_connect` reply (the one it asked for) rather than the wallet's own + * chain, so wagmi records chain 1 while the wallet sits on Base (8453) and the + * wallet client can never be built. + */ +import { mainnet, sepolia } from 'viem/chains'; +import { + ConnectorChainMismatchError, + createConfig, + http, + type CreateConnectorFn, +} from 'wagmi'; +import { connect, disconnect, getWalletClient } from 'wagmi/actions'; +import { baseAccount } from 'wagmi/connectors'; +import { + chains, + local, + supportedChainIds, +} from '../../../src/components/wallet/chains'; +import { withVerifiedChainId } from '../../../src/components/wallet/withVerifiedChainId'; + +const address = '0x907eA0e65Ecf3af503007B382E1280Aeb46104ad'; +const BASE_CHAIN_ID = 8453; +const BASE_CHAIN_HEX = '0x2105'; + +type Listener = (...args: unknown[]) => void; +interface Request { + method: string; + params?: any; +} + +/** The provider Coinbase Wallet injects into its in-app browser. */ +class FakeCoinbaseBrowserProvider { + isCoinbaseBrowser = true; + isCoinbaseWallet = true; + chainId = BASE_CHAIN_HEX; + rejectSwitch = false; + requests: Request[] = []; + private listeners = new Map>(); + + async request({ method, params }: Request) { + this.requests.push({ method, params }); + switch (method) { + case 'wallet_connect': + // Echoes the requested chains, so the first one is the chain + // the dapp asked for, not the chain the wallet is on. + return { + accounts: [{ address, capabilities: {} }], + chainIds: params[0].chainIds, + }; + case 'eth_chainId': + return this.chainId; + case 'eth_accounts': + case 'eth_requestAccounts': + return [address]; + case 'wallet_switchEthereumChain': { + if (this.rejectSwitch) { + throw Object.assign( + new Error('User rejected the request.'), + { + code: 4001, + }, + ); + } + this.chainId = params[0].chainId; + this.emit('chainChanged', this.chainId); + return null; + } + default: + throw new Error(`unexpected request ${method}`); + } + } + + find(method: string): Request | undefined { + return this.requests.find((request) => request.method === method); + } + + on(event: string, listener: Listener) { + if (!this.listeners.has(event)) this.listeners.set(event, new Set()); + this.listeners.get(event)!.add(listener); + } + + removeListener(event: string, listener: Listener) { + this.listeners.get(event)?.delete(listener); + } + + emit(event: string, ...args: unknown[]) { + this.listeners.get(event)?.forEach((listener) => listener(...args)); + } + + disconnect() {} +} + +const createTestConfig = ( + wrap: (create: CreateConnectorFn) => CreateConnectorFn = (create) => create, +) => + createConfig({ + chains, + transports: { + [mainnet.id]: http('http://127.0.0.1:1'), + [sepolia.id]: http('http://127.0.0.1:1'), + [local.id]: http('http://127.0.0.1:1'), + }, + connectors: [ + wrap( + baseAccount({ + appName: 'test', + preference: { telemetry: false }, + }), + ), + ], + multiInjectedProviderDiscovery: false, + storage: null, + ssr: false, + }); + +let provider: FakeCoinbaseBrowserProvider; +let config: ReturnType; + +const setup = (wrap?: (create: CreateConnectorFn) => CreateConnectorFn) => { + provider = new FakeCoinbaseBrowserProvider(); + window.ethereum = provider; + config = createTestConfig(wrap); +}; + +afterEach(async () => { + await disconnect(config).catch(() => undefined); + delete (window as { ethereum?: unknown }).ethereum; +}); + +describe('Base Account connector inside the Coinbase in-app browser', () => { + beforeEach(() => setup()); + + it('records the requested chain instead of the chain the wallet is on', async () => { + const [connector] = config.connectors; + + const result = await connect(config, { + connector, + chainId: mainnet.id, + }); + + expect(result.chainId).toBe(mainnet.id); + expect(await connector.getChainId()).toBe(BASE_CHAIN_ID); + expect(config.state.status).toBe('connected'); + expect(provider.find('wallet_connect')?.params[0].chainIds[0]).toBe( + '0x1', + ); + expect(provider.find('wallet_switchEthereumChain')).toBeUndefined(); + }); + + it('cannot build a wallet client while still reporting connected', async () => { + const [connector] = config.connectors; + await connect(config, { connector, chainId: mainnet.id }); + + await expect(getWalletClient(config)).rejects.toBeInstanceOf( + ConnectorChainMismatchError, + ); + expect(config.state.status).toBe('connected'); + }); + + it('reports the requested chain again when reconnecting on page load', async () => { + const [connector] = config.connectors; + + const result = await connector.connect({ isReconnecting: true }); + + expect(result.chainId).toBe(mainnet.id); + expect(await connector.getChainId()).toBe(BASE_CHAIN_ID); + }); +}); + +describe('Base Account connector wrapped with withVerifiedChainId', () => { + beforeEach(() => setup(withVerifiedChainId)); + + it('switches the wallet to the requested chain and builds the wallet client', async () => { + const [connector] = config.connectors; + + const result = await connect(config, { + connector, + chainId: mainnet.id, + }); + + expect(result.chainId).toBe(mainnet.id); + expect(provider.chainId).toBe('0x1'); + expect(provider.find('wallet_switchEthereumChain')?.params[0]).toEqual({ + chainId: '0x1', + }); + expect(await connector.getChainId()).toBe(mainnet.id); + await expect(getWalletClient(config)).resolves.toMatchObject({ + account: { address }, + chain: { id: mainnet.id }, + }); + }); + + it('fails the connection when the wallet refuses to switch', async () => { + provider.rejectSwitch = true; + const [connector] = config.connectors; + + await expect( + connect(config, { connector, chainId: mainnet.id }), + ).rejects.toThrow(); + expect(config.state.status).toBe('disconnected'); + }); + + it('reports the wallet chain when reconnecting, so the app can flag it', async () => { + const [connector] = config.connectors; + + const result = await connector.connect({ isReconnecting: true }); + + expect(result.chainId).toBe(BASE_CHAIN_ID); + expect(provider.find('wallet_switchEthereumChain')).toBeUndefined(); + expect(supportedChainIds).not.toContain(result.chainId); + }); + + it('leaves a matching connection untouched', async () => { + provider.chainId = '0x1'; + const [connector] = config.connectors; + + const result = await connect(config, { + connector, + chainId: mainnet.id, + }); + + expect(result.chainId).toBe(mainnet.id); + expect(provider.find('wallet_switchEthereumChain')).toBeUndefined(); + await expect(getWalletClient(config)).resolves.toBeDefined(); + }); +}); 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/__tests__/components/wallet/useWagmiWallet.test.tsx b/__tests__/components/wallet/useWagmiWallet.test.tsx index 086c42fe..14094ccf 100644 --- a/__tests__/components/wallet/useWagmiWallet.test.tsx +++ b/__tests__/components/wallet/useWagmiWallet.test.tsx @@ -196,6 +196,10 @@ describe('useWagmiWallet', () => { expect(usePublicClientMock).toHaveBeenCalledWith({ chainId: Network.MAINNET, }); + expect(useWalletClientMock).toHaveBeenCalledWith({ + chainId: Network.MAINNET, + query: { enabled: true }, + }); }); it('flags an unsupported network and stays inactive', () => { @@ -208,6 +212,10 @@ describe('useWagmiWallet', () => { expect(usePublicClientMock).toHaveBeenCalledWith({ chainId: undefined, }); + expect(useWalletClientMock).toHaveBeenCalledWith({ + chainId: undefined, + query: { enabled: false }, + }); }); it('recognises a Safe connection', () => { @@ -227,6 +235,28 @@ describe('useWagmiWallet', () => { const { result } = renderHook(() => useWagmiWallet()); expect(result.current.active).toBe(false); + expect(result.current.walletClientError).toBeUndefined(); + }); + + it('exposes the wallet client error and warns about it once', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const error = new Error('chain mismatch'); + error.name = 'ConnectorChainMismatchError'; + useAccountMock.mockReturnValue(connected()); + useWalletClientMock.mockReturnValue({ data: undefined, error } as any); + + const { result, rerender } = renderHook(() => useWagmiWallet()); + rerender(); + + expect(result.current.active).toBe(false); + expect(result.current.walletClientError).toBe(error); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith( + '[wallet] wallet client unavailable:', + 'ConnectorChainMismatchError', + 'chain mismatch', + ); + warn.mockRestore(); }); it('asks the wallet to switch to mainnet after connecting elsewhere', () => { diff --git a/__tests__/stubs/baseAccountSdk.ts b/__tests__/stubs/baseAccountSdk.ts new file mode 100644 index 00000000..13d5e932 --- /dev/null +++ b/__tests__/stubs/baseAccountSdk.ts @@ -0,0 +1,9 @@ +/** + * Stands in for `@base-org/account` under Jest. wagmi's baseAccount connector + * only needs `getProvider()`, and the real `createBaseAccountSDK` performs a + * HEAD fetch on creation. This mirrors the SDK's choice of the injected + * provider inside the Coinbase Wallet in-app browser. + */ +export const createBaseAccountSDK = () => ({ + getProvider: () => (window.top as Window)?.ethereum ?? window.ethereum, +}); diff --git a/jest.config.js b/jest.config.js index 6cc9f72e..09ad3e09 100644 --- a/jest.config.js +++ b/jest.config.js @@ -15,6 +15,10 @@ module.exports = { coverageReporters: ['lcov', 'text-summary'], coverageDirectory: '/coverage', moduleNameMapper: { + // wagmi's baseAccount connector imports the Base Account SDK lazily; the + // real SDK fetches on creation and is not resolvable from the project root. + '^@base-org/account$': '/__tests__/stubs/baseAccountSdk.ts', + // Handle CSS imports (with CSS modules) // https://jestjs.io/docs/webpack#mocking-css-modules '^.+\\.module\\.(css|sass|scss)$': 'identity-obj-proxy', 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/config.ts b/src/components/wallet/config.ts index 05163c88..549cffb3 100644 --- a/src/components/wallet/config.ts +++ b/src/components/wallet/config.ts @@ -23,6 +23,7 @@ import { mainnet, sepolia } from 'viem/chains'; import { createConfig, http, type Config, type CreateConnectorFn } from 'wagmi'; import { Network } from '../../utils/networks'; import { chains, local } from './chains'; +import { withVerifiedChainId } from './withVerifiedChainId'; const dappUrl = process.env.NEXT_PUBLIC_DAPP_URL; const ankrEnabled = process.env.NEXT_PUBLIC_FLAG_ANKR_ENABLED === 'true'; @@ -45,6 +46,18 @@ export const rpcUrls: Record = { process.env.NEXT_PUBLIC_RPC_URL_31337 || 'http://localhost:8545', }; +/** RainbowKit's Base wallet, with its connection chain checked against the wallet. */ +const baseWallet = ( + params: Parameters[0], +): ReturnType => { + const wallet = base(params); + return { + ...wallet, + createConnector: (details) => + withVerifiedChainId(wallet.createConnector(details)), + }; +}; + /** * RainbowKit shows exactly this list (also on mobile). Safe only appears * inside the Safe web interface and connects automatically. @@ -58,7 +71,7 @@ export const createConnectors = (): CreateConnectorFn[] => metaMaskWallet, walletConnectWallet, safeWallet, - base, + baseWallet, ], }, { 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}} + +