From f4a147ef6bd128be8fcbe1a3083618781edb83ef Mon Sep 17 00:00:00 2001 From: Mohammad Shahbaz Alam Date: Mon, 13 Jul 2026 18:03:22 +0400 Subject: [PATCH] Update MetaMask Connect docs and bump version to 2.1.1 Add comprehensive documentation for account switching, session reconnection, and error handling. Include new troubleshooting guides for cancellation normalization, sign-only dapps, and unexpected account switches. Clarify account ordering behavior, chainChanged event timing, and mobile QR modal handling. Update version requirement from @metamask/connect-evm@^2.1.0 to @metamask/connect-evm@^2.1.1 in wagmi quickstart. --- .../evm/guides/manage-user-accounts.md | 31 ++++ metamask-connect/evm/guides/sign-data/siwe.md | 9 ++ metamask-connect/evm/quickstart/wagmi.md | 6 +- metamask-connect/evm/reference/methods.md | 32 ++--- .../evm/reference/provider-api.md | 16 +++ metamask-connect/troubleshooting/index.md | 135 +++++++++++++++++- 6 files changed, 206 insertions(+), 23 deletions(-) diff --git a/metamask-connect/evm/guides/manage-user-accounts.md b/metamask-connect/evm/guides/manage-user-accounts.md index 4856c13fccb..ff2d52c7d1b 100644 --- a/metamask-connect/evm/guides/manage-user-accounts.md +++ b/metamask-connect/evm/guides/manage-user-accounts.md @@ -311,6 +311,30 @@ This one-step flow is unique to MetaMask Connect EVM's `connectWith` method. It's not part of Wagmi or other wallet libraries. ::: +## Switch or re-select accounts + +Persisted sessions reconnect silently with the previously used account, so users don't get a chance +to pick a different one after reconnecting. To re-prompt for account selection, call +[`connect`](../reference/methods.md#connect) with `forceRequest: true`: + +```javascript +const { accounts } = await evmClient.connect({ + chainIds: ['0x1'], + forceRequest: true, +}) +// The user is prompted to select an account, even if a session already exists. +``` + +:::note `wallet_requestPermissions` and `wallet_revokePermissions` +The SDK provider intercepts these EIP-2255 methods and maps them to client methods: + +- `wallet_requestPermissions` maps to `connect({ forceRequest: true })`. If you call it right after + connecting to show the account picker (a common injected-wallet pattern), the user sees a second + connection prompt. Skip this call when the provider is the SDK's. +- `wallet_revokePermissions` maps to `disconnect`. + +::: + ## Best practices Follow these best practices when authenticating users. @@ -351,6 +375,13 @@ The following table lists common authentication errors and their codes: | `-32002` | Request already pending | Disable the connect button while the request is pending. | | `-32603` | Internal JSON-RPC error | Check if MetaMask is properly installed. | +:::note +Cancellations from `connect`, `connectAndSign`, and `connectWith` don't always surface as a +top-level `code: 4001`. A cancellation can arrive wrapped with an outer `code: 53` and the wallet's +real code on `err.rpcCode`. Normalize the error before branching on it. See +[Normalize cancellations from `connect`](../../troubleshooting/index.md#normalize-cancellations-from-connect). +::: + ## Next steps See the following guides to add more functionality to your dapp: diff --git a/metamask-connect/evm/guides/sign-data/siwe.md b/metamask-connect/evm/guides/sign-data/siwe.md index 1d60d8d2c0b..2d94cc153af 100644 --- a/metamask-connect/evm/guides/sign-data/siwe.md +++ b/metamask-connect/evm/guides/sign-data/siwe.md @@ -109,6 +109,15 @@ The following HTML displays the SIWE button:

Result:

``` +:::caution Sign-only dapps and network configuration +After `connect` resolves, the provider's active chain follows the wallet's selected network. +If that network isn't in `api.supportedNetworks`, the `personal_sign` request throws +`Chain eip155: is not configured in supportedNetworks`. +Because SIWE users can be on any network, list every network they're likely to be on in +`supportedNetworks`, or pin the provider to a supported chain after connecting. +See [Chain not configured in `supportedNetworks`](../../../troubleshooting/index.md#chain-not-configured-in-supportednetworks). +::: + See the [live example](https://metamask.github.io/test-dapp/#siwe) and [test dapp source code](https://github.com/MetaMask/test-dapp) for more information. diff --git a/metamask-connect/evm/quickstart/wagmi.md b/metamask-connect/evm/quickstart/wagmi.md index 670b8b05ef0..8ba55f3959d 100644 --- a/metamask-connect/evm/quickstart/wagmi.md +++ b/metamask-connect/evm/quickstart/wagmi.md @@ -97,11 +97,11 @@ TOD0: Update with new screenshot and link Install MetaMask Connect EVM along with its peer dependencies to an existing React project: :::note Version requirements -This quickstart requires `wagmi@^3.6.0`. The `metaMask` connector is imported from the `wagmi/connectors` path (part of the `wagmi` package) and requires `@metamask/connect-evm@^2.1.0`. +This quickstart requires `wagmi@^3.6.0`. The `metaMask` connector is imported from the `wagmi/connectors` path (part of the `wagmi` package) and requires `@metamask/connect-evm@^2.1.1`. ::: ```bash npm2yarn -npm install @metamask/connect-evm@^2.1.0 wagmi@^3.6.0 viem@2.x @tanstack/react-query +npm install @metamask/connect-evm@^2.1.1 wagmi@^3.6.0 viem@2.x @tanstack/react-query ``` ### 2. Import required dependencies @@ -320,7 +320,7 @@ If you previously used `@metamask/sdk` with Wagmi, the MetaMask connector now us ```bash npm2yarn npm uninstall @metamask/sdk - npm install @metamask/connect-evm@^2.1.0 wagmi@^3.6.0 + npm install @metamask/connect-evm@^2.1.1 wagmi@^3.6.0 ``` 2. Update hook usage for Wagmi v3: diff --git a/metamask-connect/evm/reference/methods.md b/metamask-connect/evm/reference/methods.md index 855aedfbd2c..cd7f459f712 100644 --- a/metamask-connect/evm/reference/methods.md +++ b/metamask-connect/evm/reference/methods.md @@ -36,22 +36,22 @@ Creates a new EVM client instance. ### Parameters -| Name | Type | Required | Description | -| -------------------------- | ---------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `dapp.name` | `string` | Yes | Name of your dapp. | -| `dapp.url` | `string` | No | URL of your dapp. In browsers this is often set automatically; required in Node.js and React Native. | -| `dapp.iconUrl` | `string` | No | URL of your dapp icon. | -| `dapp.base64Icon` | `string` | No | Base64-encoded icon when a hosted URL is unavailable (for example, some React Native setups). Use instead of `iconUrl` when needed. | -| `api.supportedNetworks` | `Record` | No | Map of hex chain IDs to RPC URLs for read-only requests and transport. Use [`getInfuraRpcUrls`](#getinfurarpcurls) to generate Infura URLs, then merge custom endpoints. | -| `ui.headless` | `boolean` | No | Enables or disables [headless mode](../guides/headless-mode.md). The default is `false`. | -| `ui.preferExtension` | `boolean` | No | Directly connects through the MetaMask extension when it's installed. The default is `true`. | -| `mobile.preferredOpenLink` | `(deeplink: string) => void` | No | A function that's called to open a deeplink to the MetaMask Mobile App. Required in React Native. | -| `mobile.useDeeplink` | `boolean` | No | Controls use of deeplinks for mobile connection flows. | -| `ui.showInstallModal` | `boolean` | No | Shows the MetaMask install modal when the extension isn't detected. | -| `eventHandlers` | `object` | No | Optional callbacks for connection lifecycle and [provider events](provider-api.md#events). | -| `debug` | `boolean` | No | Enables verbose SDK logging. The default is `false`. | -| `skipAutoAnnounce` | `boolean` | No | Opts out of automatic EIP-6963 provider announcement (default since `@metamask/connect-evm` 2.0.0). Call `client.announceProvider()` to announce manually. | -| `analytics.enabled` | `boolean` | No | Set to `false` to opt out of analytics. | +| Name | Type | Required | Description | +| -------------------------- | ---------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `dapp.name` | `string` | Yes | Name of your dapp. | +| `dapp.url` | `string` | No | URL of your dapp. In browsers this is often set automatically; required in Node.js and React Native. | +| `dapp.iconUrl` | `string` | No | URL of your dapp icon. | +| `dapp.base64Icon` | `string` | No | Base64-encoded icon when a hosted URL is unavailable (for example, some React Native setups). Use instead of `iconUrl` when needed. | +| `api.supportedNetworks` | `Record` | No | Map of hex chain IDs to RPC URLs for read-only requests and transport. Use [`getInfuraRpcUrls`](#getinfurarpcurls) to generate Infura URLs, then merge custom endpoints. | +| `ui.headless` | `boolean` | No | Enables or disables [headless mode](../guides/headless-mode.md). The default is `false`. | +| `ui.preferExtension` | `boolean` | No | Directly connects through the MetaMask extension when it's installed. The default is `true`. | +| `mobile.preferredOpenLink` | `(deeplink: string) => void` | No | A function that's called to open a deeplink to the MetaMask Mobile App. Required in React Native. | +| `mobile.useDeeplink` | `boolean` | No | Controls use of deeplinks for mobile connection flows. | +| `ui.showInstallModal` | `boolean` | No | Shows the MetaMask install modal when the extension isn't detected. On mobile web, setting this to `true` also forces the QR/install modal and suppresses the MetaMask app deeplink. Leave at the default `false` for mobile deeplinking. | +| `eventHandlers` | `object` | No | Optional callbacks for connection lifecycle and [provider events](provider-api.md#events). | +| `debug` | `boolean` | No | Enables verbose SDK logging. The default is `false`. | +| `skipAutoAnnounce` | `boolean` | No | Opts out of automatic EIP-6963 provider announcement (default since `@metamask/connect-evm` 2.0.0). Call `client.announceProvider()` to announce manually. | +| `analytics.enabled` | `boolean` | No | Set to `false` to opt out of analytics. | ### Returns diff --git a/metamask-connect/evm/reference/provider-api.md b/metamask-connect/evm/reference/provider-api.md index 5bf69f5bfde..aab1573462d 100644 --- a/metamask-connect/evm/reference/provider-api.md +++ b/metamask-connect/evm/reference/provider-api.md @@ -199,6 +199,15 @@ the same permissions. This means that the provider emits `accountsChanged` when the user's exposed account address changes. Listen to this event to [handle accounts](../guides/manage-user-accounts.md). +:::note Account ordering in SDK sessions +The "most recently used account first" ordering is the injected-provider convention. When you use +MetaMask Connect, the account order comes from the session's permitted accounts, which isn't a +selection-first guarantee. Sessions also merge across reconnections, accumulating previously used +accounts, so a stale account can appear first. Don't assume `accounts[0]` is the active account, and +don't treat every `accountsChanged` emission as a user-initiated account switch. See +[Unexpected account switch after reconnect](../../troubleshooting/index.md#unexpected-account-switch-after-reconnect). +::: + ### `chainChanged` ```typescript @@ -210,6 +219,13 @@ The provider emits this event when the currently connected chain changes. The `chainId` is a hex string (for example, `0x1`), not a decimal number. Listen to this event to [detect a user's network](../guides/manage-networks.md). +:::note +Unlike the injected provider, which emits `chainChanged` only on an actual network switch, MetaMask +Connect also emits `chainChanged` as part of the connect handshake. If you attach a handler that +reacts to network changes (for example, reloading the page), it can fire mid-connection. Guard such +handlers so they don't run during the initial connect. +::: + ### `connect` ```typescript diff --git a/metamask-connect/troubleshooting/index.md b/metamask-connect/troubleshooting/index.md index 1f921094871..74e8f446574 100644 --- a/metamask-connect/troubleshooting/index.md +++ b/metamask-connect/troubleshooting/index.md @@ -116,6 +116,43 @@ try { } ``` +#### Normalize cancellations from `connect` + +Rejections from [`connect`](../evm/reference/methods.md#connect), +[`connectAndSign`](../evm/reference/methods.md#connectandsign), and +[`connectWith`](../evm/reference/methods.md#connectwith) don't always surface as a top-level +`code: 4001`. Depending on the transport, a cancellation can arrive as a wrapped +[`RPCInvokeMethodErr`](#error-codes) with an outer `code: 53` and the wallet's real code on +`err.rpcCode` (for example, `4001`). Requests made through +[`provider.request`](../evm/reference/provider-api.md#request) are normalized to `err.code = rpcCode`, +but `connect` rejections are not. + +Normalize defensively before branching on the error. This mirrors the SDK's own rejection heuristic, +which checks `code === 4001` or a message containing `reject`, `denied`, or `cancel`: + +```javascript +function isUserRejection(err) { + if (typeof err !== 'object' || err === null) { + return false + } + if (err.code === 4001 || err.rpcCode === 4001) { + return true + } + const message = String(err.message ?? '').toLowerCase() + return message.includes('reject') || message.includes('denied') || message.includes('cancel') +} + +try { + await client.connect({ chainIds: ['0x1'] }) +} catch (err) { + if (isUserRejection(err)) { + // User cancelled — show retry UI, don't log as an error + return + } + throw err +} +``` + ### Connection already pending (code `-32002`) A previous `connect` call has not yet resolved. @@ -126,11 +163,25 @@ Do **not** call `connect` again; the original promise resolves once the user act ### Chain not configured in `supportedNetworks` -The chain ID passed to `connect` or `wallet_switchEthereumChain` is not listed in the -`api.supportedNetworks` configuration. -The wallet rejects the request with `err.code === 4902` (unrecognized chain ID). +This appears in two situations. + +**A request throws even though `connect` succeeded.** +After `connect` resolves, the provider's active chain follows the wallet's selected network. +If the user's active network isn't in `api.supportedNetworks`, any non-cached request, including +[`personal_sign`](../evm/reference/json-rpc-api/personal_sign.mdx) for Sign-In with Ethereum, throws: + +```text +Chain eip155: is not configured in supportedNetworks. Requests cannot be made to chains not explicitly configured in supportedNetworks. +``` + +This is a plain `Error` without an `err.code`. Through ethers v6 `BrowserProvider` it surfaces as +`could not coalesce error (... code=UNKNOWN_ERROR)`. -Add every chain the dapp needs to `supportedNetworks` with a valid RPC URL: +**A network switch is rejected.** +When `connect` or `wallet_switchEthereumChain` targets a chain that isn't added to the wallet, the +wallet rejects with `err.code === 4902` (unrecognized chain ID). + +In both cases, add every chain the dapp needs to `supportedNetworks` with a valid RPC URL: ```javascript const client = await createEVMClient({ @@ -147,6 +198,29 @@ const client = await createEVMClient({ }) ``` +#### Sign-only dapps + +If your dapp only signs (for example, SIWE login) and never sends transactions, users can be on any +network when they connect. To avoid the gating error: + +- List every network your users are likely to be on in `supportedNetworks`. Ethereum Mainnet (`0x1`) + is always included in the connection request as a bootstrap fallback. +- Alternatively, after `connect` resolves, pin the provider to a supported chain using the + `selectedChainId` setter so signature requests always route to a configured chain: + +```javascript +const provider = client.getProvider() +const { chainId } = await client.connect({ chainIds: ['0x1'] }) + +// Pin to a supported chain (falling back to mainnet) before signing. +provider.selectedChainId = client.selectedChainId ?? '0x1' + +const signature = await provider.request({ + method: 'personal_sign', + params: [messageHex, address], +}) +``` + ### QR code not appearing **Cause A:** Headless mode is enabled but no `display_uri` listener is registered. @@ -222,6 +296,25 @@ If you cannot upgrade, add `data: blob:` to `connect-src` as a fallback. For the full reference, see [Content Security Policy](https://github.com/MetaMask/connect-monorepo#content-security-policy) in `metamask/connect-monorepo`. +### Mobile shows the QR modal instead of deeplinking to the app + +On mobile web, tapping connect shows the QR/install modal instead of deeplinking into the MetaMask +mobile app. + +**Cause:** `ui.showInstallModal` is set to `true`. Although the option reads as desktop-oriented, +setting it `true` forces the install modal on mobile web too, which suppresses the app deeplink. + +**Fix:** Leave `ui.showInstallModal` at its default (`false`). Mobile then deeplinks to the MetaMask +app, and desktop still shows the QR modal when no extension is detected. + +```javascript +const client = await createEVMClient({ + dapp: { name: 'My Dapp' }, + api: { supportedNetworks: { '0x1': '' } }, + // Omit ui.showInstallModal, or set it to false, so mobile deeplinks work. +}) +``` + ### MetaMask wallet not appearing in Solana Wallet Adapter **Cause A:** `createSolanaClient` has not resolved before the `WalletProvider` renders. @@ -280,6 +373,40 @@ provider.on('wallet_sessionChanged', session => { Do not call `connect` again immediately on page load if a session already exists. +### Unexpected account switch after reconnect + +After a reconnect, an `accountsChanged` emission lists a previously used account first, and a dapp +that treats `accounts[0]` as the active account interprets this as a user switching accounts. This +can trigger unwanted side effects, such as a duplicate SIWE prompt or re-authentication. + +**Cause:** Sessions merge across reconnections, so the permitted account set accumulates accounts +from earlier connections unless the dapp revokes them. The account order in an SDK session isn't a +selection-first guarantee, so a stale account can appear first, unlike the injected-provider +convention. + +**Fix:** Don't assume `accounts[0]` is the active account for session-based connections. Track the +account you actually connected with, and compare against it before reacting: + +```javascript +let activeAccount + +const { accounts } = await evmClient.connect({ chainIds: ['0x1'], forceRequest: true }) +activeAccount = accounts[0] + +const provider = evmClient.getProvider() +provider.on('accountsChanged', accounts => { + if (accounts.length === 0) { + // Disconnected. + return + } + // Only react if the connected account actually changed. + if (!accounts.includes(activeAccount)) { + activeAccount = accounts[0] + // Handle a real account switch (for example, re-authenticate). + } +}) +``` + ### `disconnect` doesn't fully disconnect Calling `disconnect(scopes)` with specific CAIP scopes only revokes those scopes, not the entire