VYPR
Moderate severityNVD Advisory· Published Apr 17, 2023· Updated Feb 5, 2025

`chainId` may be outdated if user changes chains as part of connection in @web3-react

CVE-2023-30543

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.

PackageAffected versionsPatched versions
@web3-react/coinbase-walletnpm
>= 6.0.0, < 8.0.35-beta.08.0.35-beta.0
@web3-react/eip1193npm
>= 6.0.0, < 8.0.27-beta8.0.27-beta
@web3-react/metamasknpm
>= 6.0.0, < 8.0.30-beta.08.0.30-beta.0
@web3-react/walletconnectnpm
>= 6.0.0, < 8.0.37-beta.08.0.37-beta.0

Affected products

5

Patches

1
544279f13630

fix: use up-to-date chainId/accounts when querying EIP1193-derived wallets (#749)

https://github.com/Uniswap/web3-reactZach PomerantzFeb 1, 2023via ghsa-ref
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

News mentions

0

No linked articles in our index yet.