`chainId` may be outdated if user changes chains as part of connection in @web3-react
Description
@web3-react's chainId may be outdated if users change chains during connection, leading to incorrect data derivation like wrong token addresses.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
@web3-react's chainId may be outdated if users change chains during connection, leading to incorrect data derivation like wrong token addresses.
In @web3-react, a race condition in the connection flow can cause the chainId returned by useWeb3React() to be outdated [1][4]. When a user changes chains as part of connecting, the framework may capture the chainId before the switch completes, leaving derived data inconsistent with the actual connected chain.
The vulnerability arises because chainId and accounts are requested concurrently using Promise.all [3]. In some wallets, the chainId resolves immediately while the user is prompted to select a chain, so if they choose a different chain, the original chainId persists. No special privileges are required; any user interacting with a dApp using @web3-react can trigger the issue by switching chains during connection.
An attacker could exploit this to trick users into sending funds to incorrect contract addresses. For example, a swapping app that derives wrapped token addresses from the outdated chainId might direct transactions to the wrong network's contract, resulting in loss of funds [4]. The impact depends on application logic, but the risk is high for Ethereum dApps relying on chain-specific data.
The issue is patched in PR #749 by serially requesting accounts before chainId [3]. Users of affected packages (e.g., @web3-react/coinbase-wallet, @web3-react/eip1193, @web3-react/metamask, @web3-react/walletconnect) should upgrade to the fixed beta versions [4]. No workarounds are available.
AI Insight generated on May 20, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
@web3-react/coinbase-walletnpm | >= 6.0.0, < 8.0.35-beta.0 | 8.0.35-beta.0 |
@web3-react/eip1193npm | >= 6.0.0, < 8.0.27-beta | 8.0.27-beta |
@web3-react/metamasknpm | >= 6.0.0, < 8.0.30-beta.0 | 8.0.30-beta.0 |
@web3-react/walletconnectnpm | >= 6.0.0, < 8.0.37-beta.0 | 8.0.37-beta.0 |
Affected products
5- ghsa-coords4 versionspkg:npm/%40web3-react/coinbase-walletpkg:npm/%40web3-react/eip1193pkg:npm/%40web3-react/metamaskpkg:npm/%40web3-react/walletconnect
>= 6.0.0, < 8.0.35-beta.0+ 3 more
- (no CPE)range: >= 6.0.0, < 8.0.35-beta.0
- (no CPE)range: >= 6.0.0, < 8.0.27-beta
- (no CPE)range: >= 6.0.0, < 8.0.30-beta.0
- (no CPE)range: >= 6.0.0, < 8.0.37-beta.0
- Range: @web3-react/coinbase-wallet: >= 6, < 8.0.35-beta.0
Patches
1544279f13630fix: use up-to-date chainId/accounts when querying EIP1193-derived wallets (#749)
9 files changed · +221 −195
packages/coinbase-wallet/src/index.spec.ts+11 −5 modified@@ -1,7 +1,7 @@ import { createWeb3ReactStoreAndActions } from '@web3-react/store' import type { Actions, Web3ReactStore } from '@web3-react/types' import { CoinbaseWallet } from '.' -import { MockEIP1193Provider } from '../../eip1193/src/index.spec' +import { MockEIP1193Provider } from '../../eip1193/src/mock' jest.mock( '@coinbase/wallet-sdk', @@ -19,7 +19,7 @@ const accounts: string[] = [] describe('Coinbase Wallet', () => { let store: Web3ReactStore let connector: CoinbaseWallet - let mockConnector: MockEIP1193Provider + let mockProvider: MockEIP1193Provider describe('connectEagerly = true', () => { beforeEach(async () => { @@ -34,14 +34,20 @@ describe('Coinbase Wallet', () => { }) await connector.connectEagerly().catch(() => {}) - mockConnector = connector.provider as unknown as MockEIP1193Provider - mockConnector.chainId = chainId - mockConnector.accounts = accounts + mockProvider = connector.provider as unknown as MockEIP1193Provider + mockProvider.chainId = chainId + mockProvider.accounts = accounts }) test('#activate', async () => { await connector.activate() + expect(mockProvider.eth_requestAccounts).toHaveBeenCalled() + expect(mockProvider.eth_accounts).not.toHaveBeenCalled() + expect(mockProvider.eth_chainId).toHaveBeenCalled() + expect(mockProvider.eth_chainId.mock.invocationCallOrder[0]) + .toBeGreaterThan(mockProvider.eth_requestAccounts.mock.invocationCallOrder[0]) + expect(store.getState()).toEqual({ chainId: Number.parseInt(chainId, 16), accounts,
packages/coinbase-wallet/src/index.ts+42 −49 modified@@ -85,17 +85,14 @@ export class CoinbaseWallet extends Connector { try { await this.isomorphicInitialize() - if (!this.connected) throw new Error('No existing connection') - - return Promise.all([ - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - this.provider!.request<string>({ method: 'eth_chainId' }), - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - this.provider!.request<string[]>({ method: 'eth_accounts' }), - ]).then(([chainId, accounts]) => { - if (!accounts.length) throw new Error('No accounts returned') - this.actions.update({ chainId: parseChainId(chainId), accounts }) - }) + if (!this.provider || !this.connected) throw new Error('No existing connection') + + // Wallets may resolve eth_chainId and hang on eth_accounts pending user interaction, which may include changing + // chains; they should be requested serially, with accounts first, so that the chainId can settle. + const accounts = await this.provider.request<string[]>({ method: 'eth_accounts' }) + if (!accounts.length) throw new Error('No accounts returned') + const chainId = await this.provider.request<string>({ method: 'eth_chainId' }) + this.actions.update({ chainId: parseChainId(chainId), accounts }) } catch (error) { cancelActivation() throw error @@ -117,20 +114,18 @@ export class CoinbaseWallet extends Connector { ? desiredChainIdOrChainParameters : desiredChainIdOrChainParameters?.chainId - if (this.connected) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - if (!desiredChainId || desiredChainId === parseChainId(this.provider!.chainId)) return + if (this.provider && this.connected) { + if (!desiredChainId || desiredChainId === parseChainId(this.provider.chainId)) return const desiredChainIdHex = `0x${desiredChainId.toString(16)}` - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return this.provider!.request<void>({ + return this.provider.request<void>({ method: 'wallet_switchEthereumChain', params: [{ chainId: desiredChainIdHex }], }).catch(async (error: ProviderRpcError) => { if (error.code === 4902 && typeof desiredChainIdOrChainParameters !== 'number') { + if (!this.provider) throw new Error('No provider') // if we're here, we can try to add a new network - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return this.provider!.request<void>({ + return this.provider.request<void>({ method: 'wallet_addEthereumChain', params: [{ ...desiredChainIdOrChainParameters, chainId: desiredChainIdHex }], }) @@ -144,38 +139,36 @@ export class CoinbaseWallet extends Connector { try { await this.isomorphicInitialize() + if (!this.provider) throw new Error('No provider') - return Promise.all([ - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - this.provider!.request<string>({ method: 'eth_chainId' }), - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - this.provider!.request<string[]>({ method: 'eth_requestAccounts' }), - ]).then(([chainId, accounts]) => { - const receivedChainId = parseChainId(chainId) - - if (!desiredChainId || desiredChainId === receivedChainId) - return this.actions.update({ chainId: receivedChainId, accounts }) - - // if we're here, we can try to switch networks - const desiredChainIdHex = `0x${desiredChainId.toString(16)}` - return this.provider - ?.request<void>({ - method: 'wallet_switchEthereumChain', - params: [{ chainId: desiredChainIdHex }], - }) - .catch(async (error: ProviderRpcError) => { - if (error.code === 4902 && typeof desiredChainIdOrChainParameters !== 'number') { - // if we're here, we can try to add a new network - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return this.provider!.request<void>({ - method: 'wallet_addEthereumChain', - params: [{ ...desiredChainIdOrChainParameters, chainId: desiredChainIdHex }], - }) - } - - throw error - }) - }) + // Wallets may resolve eth_chainId and hang on eth_accounts pending user interaction, which may include changing + // chains; they should be requested serially, with accounts first, so that the chainId can settle. + const accounts = await this.provider.request<string[]>({ method: 'eth_requestAccounts' }) + const chainId = await this.provider.request<string>({ method: 'eth_chainId' }) + const receivedChainId = parseChainId(chainId) + + if (!desiredChainId || desiredChainId === receivedChainId) + return this.actions.update({ chainId: receivedChainId, accounts }) + + // if we're here, we can try to switch networks + const desiredChainIdHex = `0x${desiredChainId.toString(16)}` + return this.provider + ?.request<void>({ + method: 'wallet_switchEthereumChain', + params: [{ chainId: desiredChainIdHex }], + }) + .catch(async (error: ProviderRpcError) => { + if (error.code === 4902 && typeof desiredChainIdOrChainParameters !== 'number') { + if (!this.provider) throw new Error('No provider') + // if we're here, we can try to add a new network + return this.provider.request<void>({ + method: 'wallet_addEthereumChain', + params: [{ ...desiredChainIdOrChainParameters, chainId: desiredChainIdHex }], + }) + } + + throw error + }) } catch (error) { cancelActivation() throw error
packages/eip1193/src/index.spec.ts+6 −42 modified@@ -4,6 +4,7 @@ import { createWeb3ReactStoreAndActions } from '@web3-react/store' import type { Actions, ProviderRpcError, RequestArguments, Web3ReactStore } from '@web3-react/types' import { EventEmitter } from 'node:events' import { EIP1193 } from '.' +import { MockEIP1193Provider } from './mock' class MockProviderRpcError extends Error { public code: number @@ -13,47 +14,6 @@ class MockProviderRpcError extends Error { } } -export class MockEIP1193Provider extends EventEmitter { - public chainId?: string - public accounts?: string[] - - public eth_chainId = jest.fn((chainId?: string) => chainId) - public eth_accounts = jest.fn((accounts?: string[]) => accounts) - public eth_requestAccounts = jest.fn((accounts?: string[]) => accounts) - - public request(x: RequestArguments): Promise<unknown> { - // make sure to throw if we're "not connected" - if (!this.chainId) return Promise.reject(new Error()) - - switch (x.method) { - case 'eth_chainId': - return Promise.resolve(this.eth_chainId(this.chainId)) - case 'eth_accounts': - return Promise.resolve(this.eth_accounts(this.accounts)) - case 'eth_requestAccounts': - return Promise.resolve(this.eth_requestAccounts(this.accounts)) - default: - throw new Error() - } - } - - public emitConnect(chainId: string) { - this.emit('connect', { chainId }) - } - - public emitDisconnect(error: ProviderRpcError) { - this.emit('disconnect', error) - } - - public emitChainChanged(chainId: string) { - this.emit('chainChanged', chainId) - } - - public emitAccountsChanged(accounts: string[]) { - this.emit('accountsChanged', accounts) - } -} - const chainId = '0x1' const accounts: string[] = [] @@ -132,7 +92,7 @@ describe('EIP1193', () => { mockProvider.accounts = accounts connector = new EIP1193({ actions, provider: mockProvider }) - await connector.connectEagerly().catch(() => {}) + await connector.connectEagerly() expect(store.getState()).toEqual({ chainId: 1, @@ -143,6 +103,8 @@ describe('EIP1193', () => { expect(mockProvider.eth_chainId.mock.calls.length).toBe(1) expect(mockProvider.eth_accounts.mock.calls.length).toBe(1) expect(mockProvider.eth_requestAccounts.mock.calls.length).toBe(0) + expect(mockProvider.eth_chainId.mock.invocationCallOrder[0]) + .toBeGreaterThan(mockProvider.eth_accounts.mock.invocationCallOrder[0]) }) }) @@ -170,6 +132,8 @@ describe('EIP1193', () => { expect(mockProvider.eth_chainId.mock.calls.length).toBe(1) expect(mockProvider.eth_accounts.mock.calls.length).toBe(0) expect(mockProvider.eth_requestAccounts.mock.calls.length).toBe(1) + expect(mockProvider.eth_chainId.mock.invocationCallOrder[0]) + .toBeGreaterThan(mockProvider.eth_requestAccounts.mock.invocationCallOrder[0]) }) test(`chainId = ${chainId}`, async () => {
packages/eip1193/src/index.ts+16 −25 modified@@ -42,39 +42,30 @@ export class EIP1193 extends Connector { }) } - /** {@inheritdoc Connector.connectEagerly} */ - public async connectEagerly(): Promise<void> { + private async activateAccounts(requestAccounts: () => Promise<string[]>): Promise<void> { const cancelActivation = this.actions.startActivation() - return Promise.all([ - this.provider.request({ method: 'eth_chainId' }) as Promise<string>, - this.provider.request({ method: 'eth_accounts' }) as Promise<string[]>, - ]) - .then(([chainId, accounts]) => { - this.actions.update({ chainId: parseChainId(chainId), accounts }) - }) - .catch((error) => { + try { + // Wallets may resolve eth_chainId and hang on eth_accounts pending user interaction, which may include changing + // chains; they should be requested serially, with accounts first, so that the chainId can settle. + const accounts = await requestAccounts() + const chainId = await this.provider.request({ method: 'eth_chainId' }) as string + this.actions.update({ chainId: parseChainId(chainId), accounts }) + } catch (error) { cancelActivation() throw error - }) + } + } + + /** {@inheritdoc Connector.connectEagerly} */ + public async connectEagerly(): Promise<void> { + return this.activateAccounts(() => this.provider.request({ method: 'eth_accounts' }) as Promise<string[]>) } /** {@inheritdoc Connector.activate} */ public async activate(): Promise<void> { - const cancelActivation = this.actions.startActivation() - - return Promise.all([ - this.provider.request({ method: 'eth_chainId' }) as Promise<string>, - this.provider + return this.activateAccounts(() => this.provider .request({ method: 'eth_requestAccounts' }) - .catch(() => this.provider.request({ method: 'eth_accounts' })) as Promise<string[]>, - ]) - .then(([chainId, accounts]) => { - this.actions.update({ chainId: parseChainId(chainId), accounts }) - }) - .catch((error) => { - cancelActivation() - throw error - }) + .catch(() => this.provider.request({ method: 'eth_accounts' })) as Promise<string[]>) } }
packages/eip1193/src/mock.ts+43 −0 added@@ -0,0 +1,43 @@ +import type { ProviderRpcError, RequestArguments } from '@web3-react/types' +import { EventEmitter } from 'node:events' + +export class MockEIP1193Provider extends EventEmitter { + public chainId?: string + public accounts?: string[] + + public eth_chainId = jest.fn((chainId?: string) => chainId) + public eth_accounts = jest.fn((accounts?: string[]) => accounts) + public eth_requestAccounts = jest.fn((accounts?: string[]) => accounts) + + public request(x: RequestArguments): Promise<unknown> { + // make sure to throw if we're "not connected" + if (!this.chainId) return Promise.reject(new Error()) + + switch (x.method) { + case 'eth_chainId': + return Promise.resolve(this.eth_chainId(this.chainId)) + case 'eth_accounts': + return Promise.resolve(this.eth_accounts(this.accounts)) + case 'eth_requestAccounts': + return Promise.resolve(this.eth_requestAccounts(this.accounts)) + default: + throw new Error() + } + } + + public emitConnect(chainId: string) { + this.emit('connect', { chainId }) + } + + public emitDisconnect(error: ProviderRpcError) { + this.emit('disconnect', error) + } + + public emitChainChanged(chainId: string) { + this.emit('chainChanged', chainId) + } + + public emitAccountsChanged(accounts: string[]) { + this.emit('accountsChanged', accounts) + } +}
packages/metamask/src/index.spec.ts+27 −2 modified@@ -1,10 +1,10 @@ import { createWeb3ReactStoreAndActions } from '@web3-react/store' import type { Actions, Web3ReactStore } from '@web3-react/types' import { MetaMask } from '.' -import { MockEIP1193Provider } from '../../eip1193/src/index.spec' +import { MockEIP1193Provider } from '../../eip1193/src/mock' const chainId = '0x1' -const accounts: string[] = [] +const accounts: string[] = ['0x0000000000000000000000000000000000000000'] describe('MetaMask', () => { let mockProvider: MockEIP1193Provider @@ -26,12 +26,37 @@ describe('MetaMask', () => { connector = new MetaMask({ actions }) }) + test('#connectEagerly', async () => { + mockProvider.chainId = chainId + mockProvider.accounts = accounts + + await connector.connectEagerly() + + expect(mockProvider.eth_requestAccounts).not.toHaveBeenCalled() + expect(mockProvider.eth_accounts).toHaveBeenCalled() + expect(mockProvider.eth_chainId).toHaveBeenCalled() + expect(mockProvider.eth_chainId.mock.invocationCallOrder[0]) + .toBeGreaterThan(mockProvider.eth_accounts.mock.invocationCallOrder[0]) + + expect(store.getState()).toEqual({ + chainId: Number.parseInt(chainId, 16), + accounts, + activating: false, + }) + }) + test('#activate', async () => { mockProvider.chainId = chainId mockProvider.accounts = accounts await connector.activate() + expect(mockProvider.eth_requestAccounts).toHaveBeenCalled() + expect(mockProvider.eth_accounts).not.toHaveBeenCalled() + expect(mockProvider.eth_chainId).toHaveBeenCalled() + expect(mockProvider.eth_chainId.mock.invocationCallOrder[0]) + .toBeGreaterThan(mockProvider.eth_requestAccounts.mock.invocationCallOrder[0]) + expect(store.getState()).toEqual({ chainId: Number.parseInt(chainId, 16), accounts,
packages/metamask/src/index.ts+51 −52 modified@@ -9,7 +9,12 @@ import type { } from '@web3-react/types' import { Connector } from '@web3-react/types' -type MetaMaskProvider = Provider & { isMetaMask?: boolean; isConnected?: () => boolean; providers?: MetaMaskProvider[] } +type MetaMaskProvider = Provider & { + isMetaMask?: boolean; isConnected?: () => boolean + providers?: MetaMaskProvider[] + get chainId(): string + get accounts(): string[] +} export class NoMetaMaskError extends Error { public constructor() { @@ -93,27 +98,23 @@ export class MetaMask extends Connector { public async connectEagerly(): Promise<void> { const cancelActivation = this.actions.startActivation() - await this.isomorphicInitialize() - if (!this.provider) return cancelActivation() - - return Promise.all([ - this.provider.request({ method: 'eth_chainId' }) as Promise<string>, - this.provider.request({ method: 'eth_accounts' }) as Promise<string[]>, - ]) - .then(([chainId, accounts]) => { - if (accounts.length) { - this.actions.update({ chainId: parseChainId(chainId), accounts }) - } else { - throw new Error('No accounts returned') - } - }) - .catch((error) => { + try { + await this.isomorphicInitialize() + if (!this.provider) return cancelActivation() + + // Wallets may resolve eth_chainId and hang on eth_accounts pending user interaction, which may include changing + // chains; they should be requested serially, with accounts first, so that the chainId can settle. + const accounts = await this.provider.request({ method: 'eth_accounts' }) as string[] + if (!accounts.length) throw new Error('No accounts returned') + const chainId = await this.provider.request({ method: 'eth_chainId' }) as string + this.actions.update({ chainId: parseChainId(chainId), accounts }) + } catch (error) { console.debug('Could not connect eagerly', error) // we should be able to use `cancelActivation` here, but on mobile, metamask emits a 'connect' // event, meaning that chainId is updated, and cancelActivation doesn't work because an intermediary // update has occurred, so we reset state instead this.actions.resetState() - }) + } } /** @@ -133,42 +134,40 @@ export class MetaMask extends Connector { .then(async () => { if (!this.provider) throw new NoMetaMaskError() - return Promise.all([ - this.provider.request({ method: 'eth_chainId' }) as Promise<string>, - this.provider.request({ method: 'eth_requestAccounts' }) as Promise<string[]>, - ]).then(([chainId, accounts]) => { - const receivedChainId = parseChainId(chainId) - const desiredChainId = - typeof desiredChainIdOrChainParameters === 'number' - ? desiredChainIdOrChainParameters - : desiredChainIdOrChainParameters?.chainId - - // if there's no desired chain, or it's equal to the received, update - if (!desiredChainId || receivedChainId === desiredChainId) - return this.actions.update({ chainId: receivedChainId, accounts }) - - const desiredChainIdHex = `0x${desiredChainId.toString(16)}` - - // if we're here, we can try to switch networks - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return this.provider!.request({ - method: 'wallet_switchEthereumChain', - params: [{ chainId: desiredChainIdHex }], - }) - .catch((error: ProviderRpcError) => { - if (error.code === 4902 && typeof desiredChainIdOrChainParameters !== 'number') { - // if we're here, we can try to add a new network - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return this.provider!.request({ - method: 'wallet_addEthereumChain', - params: [{ ...desiredChainIdOrChainParameters, chainId: desiredChainIdHex }], - }) - } - - throw error - }) - .then(() => this.activate(desiredChainId)) + // Wallets may resolve eth_chainId and hang on eth_accounts pending user interaction, which may include changing + // chains; they should be requested serially, with accounts first, so that the chainId can settle. + const accounts = await this.provider.request({ method: 'eth_requestAccounts' }) as string[] + const chainId = await this.provider.request({ method: 'eth_chainId' }) as string + const receivedChainId = parseChainId(chainId) + const desiredChainId = + typeof desiredChainIdOrChainParameters === 'number' + ? desiredChainIdOrChainParameters + : desiredChainIdOrChainParameters?.chainId + + // if there's no desired chain, or it's equal to the received, update + if (!desiredChainId || receivedChainId === desiredChainId) + return this.actions.update({ chainId: receivedChainId, accounts }) + + const desiredChainIdHex = `0x${desiredChainId.toString(16)}` + + // if we're here, we can try to switch networks + return this.provider.request({ + method: 'wallet_switchEthereumChain', + params: [{ chainId: desiredChainIdHex }], }) + .catch((error: ProviderRpcError) => { + if (error.code === 4902 && typeof desiredChainIdOrChainParameters !== 'number') { + if (!this.provider) throw new Error('No provider') + // if we're here, we can try to add a new network + return this.provider.request({ + method: 'wallet_addEthereumChain', + params: [{ ...desiredChainIdOrChainParameters, chainId: desiredChainIdHex }], + }) + } + + throw error + }) + .then(() => this.activate(desiredChainId)) }) .catch((error) => { cancelActivation?.()
packages/walletconnect/src/index.spec.ts+13 −5 modified@@ -2,7 +2,7 @@ import { createWeb3ReactStoreAndActions } from '@web3-react/store' import type { Actions, RequestArguments, Web3ReactStore } from '@web3-react/types' import EventEmitter from 'node:events' import { WalletConnect } from '.' -import { MockEIP1193Provider } from '../../eip1193/src/index.spec' +import { MockEIP1193Provider } from '../../eip1193/src/mock' // necessary because walletconnect returns chainId as a number class MockMockWalletConnectProvider extends MockEIP1193Provider { @@ -29,7 +29,7 @@ const accounts: string[] = [] describe('WalletConnect', () => { let store: Web3ReactStore let connector: WalletConnect - let mockConnector: MockMockWalletConnectProvider + let mockProvider: MockMockWalletConnectProvider describe('works', () => { beforeEach(async () => { @@ -40,11 +40,19 @@ describe('WalletConnect', () => { test('#activate', async () => { await connector.connectEagerly().catch(() => {}) - mockConnector = connector.provider as unknown as MockMockWalletConnectProvider - mockConnector.chainId = chainId - mockConnector.accounts = accounts + + mockProvider = connector.provider as unknown as MockMockWalletConnectProvider + mockProvider.chainId = chainId + mockProvider.accounts = accounts + await connector.activate() + expect(mockProvider.eth_requestAccounts).toHaveBeenCalled() + expect(mockProvider.eth_accounts).not.toHaveBeenCalled() + expect(mockProvider.eth_chainId_number).toHaveBeenCalled() + expect(mockProvider.eth_chainId_number.mock.invocationCallOrder[0]) + .toBeGreaterThan(mockProvider.eth_requestAccounts.mock.invocationCallOrder[0]) + expect(store.getState()).toEqual({ chainId: Number.parseInt(chainId, 16), accounts,
packages/walletconnect/src/index.ts+12 −15 modified@@ -125,15 +125,13 @@ export class WalletConnect extends Connector { await this.isomorphicInitialize() if (!this.provider?.connected) throw Error('No existing connection') - // for walletconnect, we always use sequential instead of parallel fetches because otherwise - // chainId defaults to 1 even if the connecting wallet isn't on mainnet - const accounts = await this.provider?.request<string[]>({ method: 'eth_accounts' }) + // Wallets may resolve eth_chainId and hang on eth_accounts pending user interaction, which may include changing + // chains; they should be requested serially, with accounts first, so that the chainId can settle. + const accounts = await this.provider.request<string[]>({ method: 'eth_accounts' }) if (!accounts.length) throw new Error('No accounts returned') - const chainId = await this.provider - .request<string | number>({ method: 'eth_chainId' }) - .then((chainId) => parseChainId(chainId)) + const chainId = await this.provider.request<string>({ method: 'eth_chainId' }) - this.actions.update({ chainId, accounts }) + this.actions.update({ chainId: parseChainId(chainId), accounts }) } catch (error) { cancelActivation() throw error @@ -147,7 +145,7 @@ export class WalletConnect extends Connector { // this early return clause catches some common cases if activate is called after connection has been established if (this.provider?.connected) { if (!desiredChainId || desiredChainId === this.provider.chainId) return - // beacuse the provider is already connected, we can ignore the suppressUserPrompts + // because the provider is already connected, we can ignore the suppressUserPrompts return this.provider.request<void>({ method: 'wallet_switchEthereumChain', params: [{ chainId: `0x${desiredChainId.toString(16)}` }], @@ -161,22 +159,21 @@ export class WalletConnect extends Connector { try { await this.isomorphicInitialize(desiredChainId) + if (!this.provider) throw new Error('No provider') + // Wallets may resolve eth_chainId and hang on eth_accounts pending user interaction, which may include changing + // chains; they should be requested serially, with accounts first, so that the chainId can settle. const accounts = await this.provider - ?.request<string[]>({ method: 'eth_requestAccounts' }) + .request<string[]>({ method: 'eth_requestAccounts' }) // if a user triggers the walletconnect modal, closes it, and then tries to connect again, // the modal will not trigger. by deactivating when this happens, we prevent the bug. .catch(async (error: Error) => { if (error?.message === 'User closed modal') await this.deactivate() throw error }) + const chainId = await this.provider.request<string>({ method: 'eth_chainId' }) - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const chainId = await this.provider!.request<string | number>({ method: 'eth_chainId' }).then((chainId) => - parseChainId(chainId) - ) - - this.actions.update({ chainId, accounts }) + this.actions.update({ chainId: parseChainId(chainId), accounts }) } catch (error) { cancelActivation() throw error
Vulnerability mechanics
Root cause
"Parallel RPC requests for chainId and accounts allow the chainId to be captured before a user-initiated chain switch settles."
Attack vector
An attacker can exploit this by inducing a user to change chains as part of the wallet connection flow (e.g., via a social engineering attack that prompts a network switch). Because the old code issued `eth_chainId` and `eth_accounts` in parallel via `Promise.all`, the wallet could resolve `eth_chainId` first with the original chain ID and then hang on `eth_accounts` while the user interacts with the wallet UI to switch chains. The application would then use the outdated `chainId` for downstream logic such as deriving token contract addresses, potentially causing the user to send funds to an incorrect address. No authentication or special configuration is required beyond the user interacting with a dApp that uses the affected library [patch_id=1640843].
Affected code
The vulnerability affects the `connectEagerly()` and `activate()` methods in the MetaMask, CoinbaseWallet, EIP1193, and WalletConnect connector packages. The core issue is in `packages/eip1193/src/index.ts` where `eth_chainId` and `eth_accounts`/`eth_requestAccounts` were previously requested via `Promise.all`, allowing the chainId to settle to a stale value if the user changed chains during the connection flow. The same pattern existed in `packages/metamask/src/index.ts`, `packages/coinbase-wallet/src/index.ts`, and `packages/walletconnect/src/index.ts`.
What the fix does
The patch changes the RPC request order from parallel (`Promise.all`) to serial, requesting accounts first (`eth_accounts` or `eth_requestAccounts`) and then `eth_chainId`. This ensures that if the wallet hangs on the accounts request while the user changes chains, the subsequent `eth_chainId` call will return the final, correct chain ID. The same fix is applied consistently across the MetaMask, CoinbaseWallet, EIP1193, and WalletConnect connectors. Additionally, the EIP1193 connector extracts the shared logic into a private `activateAccounts()` method to avoid code duplication [patch_id=1640843].
Preconditions
- inputThe user must change chains as part of the wallet connection flow (e.g., via a wallet UI prompt).
- configThe dApp must use an affected version of @web3-react and derive data (e.g., token contract addresses) from the chainId returned by useWeb3React().
Generated on May 23, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4- github.com/advisories/GHSA-8pf3-6fgr-3g3gghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2023-30543ghsaADVISORY
- github.com/Uniswap/web3-react/pull/749ghsax_refsource_MISCWEB
- github.com/Uniswap/web3-react/security/advisories/GHSA-8pf3-6fgr-3g3gghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.