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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
241 changes: 241 additions & 0 deletions __tests__/components/wallet/baseAccountChainMismatch.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, Set<Listener>>();

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<typeof createTestConfig>;

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();
});
});
Loading
Loading