MetaMask Connect — Conventions & Guardrails
Always-on guardrails for the MetaMask Connect SDK, distilled from the MetaMask Connect Cursor plugin rules. Apply these whenever you generate or review MetaMask Connect (@metamask/connect-evm / -multichain / -solana) or wagmi metaMask() connector code.
MetaMask Connect Best Practices
Best practices for MetaMask Connect SDK — import paths, singleton behavior, required config, error handling, and connection state management
Import Paths
- Import EVM client from
@metamask/connect-evm - Import multichain client from
@metamask/connect-multichain - Import Solana client from
@metamask/connect-solana - Never import from internal sub-packages like
@metamask/connect/dist/...or@metamask/connect-evm/src/... - Use the wagmi connector from the published entrypoint your installed version exposes; do not assume
@metamask/connect-evm/wagmiexists unless your package version exports it @metamask/connect-multichainis a regular dependency of both@metamask/connect-evmand@metamask/connect-solana(since 2.1.0) and is installed transitively — you do not need to add it yourself. (Only the 2.0.0 releases briefly made it a peer dependency.) Both clients warn at runtime on duplicate or mismatched@metamask/connect-multichainresolutions; if you do depend on it directly (e.g. to usecreateMultichainClient), use^1.0.0— it is a stable 1.x package following strict semver
Required Configuration
dapp.nameis always required — it appears in the MetaMask connection promptdapp.urlis required in Node.js and React Native environments (nowindow.locationavailable)dapp.urlin browser can default towindow.location.hrefbut explicit is saferdapp.iconUrlis optional — displayed in MetaMask connection UIdapp.base64Iconis an alternative toiconUrl— pass a base64-encoded icon string directly (useful when a hosted URL is unavailable, e.g., in React Native)
Supported Networks
- Every chain the dApp interacts with must be in
api.supportedNetworkswith a reachable RPC URL - Use
getInfuraRpcUrls({ infuraApiKey: 'API_KEY', chainIds?: Hex[] })to populate common EVM chains — it returns a hex-keyed map forcreateEVMClient - Use
getInfuraRpcUrls({ infuraApiKey: 'API_KEY', caipChainIds?: string[] })to populate CAIP-2 chains forcreateMultichainClient - Use
getInfuraRpcUrls({ infuraApiKey: 'API_KEY', networks: SolanaNetwork[] })from@metamask/connect-solanato populate a network-name-keyed map forcreateSolanaClient—networksis required - Chain
0x1(Ethereum mainnet) is auto-included in the EVMconnect()permission request if not specified — but it is not auto-added tosupportedNetworks, which must list every chain explicitly - Making an RPC request whose active chain is missing from
supportedNetworksthrows "not configured in supportedNetworks" (the check runs in the provider'srequest()path, not inconnect())
Singleton Behavior
createMultichainClientis the singleton shared core instancecreateEVMClientandcreateSolanaClientcreate chain-specific wrappers on top of that shared multichain core- Repeated client creation still reuses the existing multichain session and merged core options, but EVM/Solana wrappers can attach fresh listeners
- The multichain core keeps the
dappobject from the first call and does not overwrite it later - Never call
create*Clientinside a React component render — call it once at app startup - Do not wrap client creation in
useEffector other hooks that may re-run
Error Handling
- Code
4001: User rejected the request — show retry UI, do not log as application error. On the EVM provider it appears aserr.code; on the multichain client it appears aserr.rpcCode(see below) - Code
-32002("request already pending") comes from the extension transport only — multichain MWP concurrentconnect()instead throws a plainError("Existing connection is pending...") with no numeric code - Wrap all
connect(),invokeMethod(), and signing calls in try/catch - Multichain
invokeMethod()errors are wrapped inRPCInvokeMethodErr(its owncodeis53); the wallet's original code/message/data are preserved onrpcCode/rpcMessage/rpcData:import { RPCInvokeMethodErr } from '@metamask/connect-multichain'; try { await client.invokeMethod({ scope, request }); } catch (err) { if (err instanceof RPCInvokeMethodErr && err.rpcCode === 4001) { // user rejection } } - Other exported error classes:
RPCHttpErr(code 50),RPCReadonlyResponseErr(51),RPCReadonlyRequestErr(52) — for RPC-node-routed read calls. (There are noProtocolError/StorageError/RpcErrorexports.)
Connection State
- Check connection state before making signing requests
- Listen for
wallet_sessionChangedto track session state reactively - Do not call
connect()on page reload if a session already exists — listen for session restoration via events - Multichain client:
disconnect()with no arguments revokes all scopes and terminates the session;disconnect(scopes)revokes only those scopes - EVM client:
disconnect()revokes only theeip155:*scopes — Solana scopes on the same session survive; full teardown requires the multichain client disconnect(scopes)with specific scopes only revokes those scopes
Unsupported Methods
- The EVM client rejects certain methods with
Method: <name> is not supported by Metamask Connect/EVM(they are not silently ignored) - Since
@metamask/connect-evm2.0.0,wallet_requestPermissionsresolves to a spec-shaped requested-permissions array — butconnect()remains the canonical way to establish permissions
EVM Chain ID Format
EVM chain ID formatting rules — hex string requirements, common chain IDs, CAIP-2 conversion, switchChain fallback, and supportedNetworks validation
Hex String Requirement
- Chain IDs in MetaMask Connect must always be hex strings:
'0x1'not1or'1' - All
chainIdsarrays,supportedNetworkskeys, andswitchChainparameters expect hex format - Passing a number or decimal string will cause silent failures or runtime errors
- Use
'0x' + chainId.toString(16)to convert from decimal to hex
Common Chain IDs
| Network | Decimal | Hex | CAIP-2 Scope |
|---|---|---|---|
| Ethereum Mainnet | 1 | 0x1 |
eip155:1 |
| Sepolia | 11155111 | 0xaa36a7 |
eip155:11155111 |
| Polygon | 137 | 0x89 |
eip155:137 |
| Arbitrum One | 42161 | 0xa4b1 |
eip155:42161 |
| Optimism | 10 | 0xa |
eip155:10 |
| Base | 8453 | 0x2105 |
eip155:8453 |
| Avalanche C-Chain | 43114 | 0xa86a |
eip155:43114 |
| BNB Smart Chain | 56 | 0x38 |
eip155:56 |
| Celo | 42220 | 0xa4ec |
eip155:42220 |
| Linea | 59144 | 0xe708 |
eip155:59144 |
CAIP-2 Conversion
- EVM CAIP-2 format is
eip155:<decimal-chainId>— always uses decimal, not hex - EVM RPC / EIP-1193 format uses hex strings (
0x1) - Multichain
invokeMethodscope uses CAIP-2 (eip155:1) - EVM client
connect({ chainIds })uses hex strings (['0x1']) - Convert: hex
0x89→ decimal137→ CAIP-2eip155:137
Auto-Included Chain
0x1(Ethereum mainnet) is automatically included in the EVM client'sconnect()permission request even if you don't pass it inchainIds- It is not injected into
api.supportedNetworks— that map must explicitly contain every chain you use (including mainnet), andcreateEVMClientthrows if it is empty - All chains need valid RPC URLs in
supportedNetworks - If you use Infura RPC URLs, make sure the needed chains are enabled for your Infura project/API key
Wagmi Connector
- The wagmi MetaMask connector is imported from
wagmi/connectors:import { metaMask } from 'wagmi/connectors'— it requires@metamask/connect-evmas a peer dependency - Use
getInfuraRpcUrls({ infuraApiKey: 'API_KEY', chainIds?: Hex[] })from@metamask/connect-evmto populatesupportedNetworks— returns a hex-chain-ID-keyed map of Infura RPC URLs (e.g.{ '0x1': 'https://...', '0x89': 'https://...' });chainIdsis optional and filters to specific hex chain IDs - The multichain equivalent in
@metamask/connect-multichainisgetInfuraRpcUrls({ infuraApiKey: 'API_KEY', caipChainIds?: string[] })— returns a CAIP-2-keyed map (e.g.{ 'eip155:1': 'https://...' }) and accepts CAIP-2 IDs for filtering
Switch Chain Fallback
- Use
client.switchChain({ chainId, chainConfiguration? })to switch the active EVM chain - If the chain is not already added in MetaMask,
wallet_switchEthereumChaincan fail - Pass
chainConfigurationdirectly toclient.switchChain()as thewallet_addEthereumChainfallback payload - In wagmi flows, the connector passes the same fallback config through to the underlying SDK
switchChain()call - Since
@metamask/connect-evm1.2.0, callingswitchChain({ chainId })without achainConfigurationnow surfaces the wallet's originalUnrecognized chain IDerror (EIP-1193 code4902) instead of the previousNo chain configuration found.wrapper. Catch the raw code in yourcatchblock and either retry with achainConfigurationfallback, callwallet_addEthereumChainexplicitly, or prompt the user to add the chain — do not pattern-match on the legacy"No chain configuration found"message string - Since
@metamask/connect-evm2.0.0, MWP-backed (Mobile Wallet Protocol) EIP-1193 requests reject with the wallet's error consistently with the default transport, soswitchChain()no longer inspects returned error payloads — wallet errors (including4902) always arrive as a rejected promise. Handle switch-chain failures purely incatch; do not check for an error object in the resolved value ofswitchChain()or aprovider.request({ method: 'wallet_switchEthereumChain' })call
Validation Error
- Making an RPC request whose active chain's CAIP scope is missing from
supportedNetworksthrowsChain eip155:<id> is not configured in supportedNetworks. Requests cannot be made to chains not explicitly configured in supportedNetworks. - This check lives in the EIP-1193 provider's
request()path — not inconnect().connect()only validates thatchainIdsis a non-empty array, andwallet_switchEthereumChainis forwarded to the wallet (it is not gated bysupportedNetworks). - Fix: add every chain the dApp reads from to
supportedNetworkswith a valid RPC URL before selecting it
EVM Provider Event Handling
EVM provider and connect-evm event handling — EIP-1193 events, SDK eventHandlers, payload types, display_uri timing, and transport events
EIP-1193 Events (EVM Provider)
connect— fired when the provider establishes a connection; payload:{ chainId: Hex; accounts: Address[] }disconnect— fired when the provider loses connection; no payloadaccountsChanged— fired when the user's accounts change; payload:string[](array of addresses)chainChanged— fired when the active chain changes; payload:string(hex chain ID, not decimal)message— part of the EIP-1193 provider event type (payload:{ type: string; data: unknown }), but not currently emitted by@metamask/connect-evm; don't rely on it for subscription delivery
const provider = client.getProvider();
provider.on('accountsChanged', (accounts: string[]) => {
console.log('New accounts:', accounts);
});
provider.on('chainChanged', (chainId: string) => {
// chainId is HEX (e.g., '0x1'), NOT decimal
console.log('New chain:', chainId);
});
provider.on('connect', ({ chainId, accounts }: { chainId: string; accounts: string[] }) => {
console.log('Connected to chain:', chainId, 'accounts:', accounts);
});
provider.on('disconnect', () => {
// No payload — the event itself is the signal
console.log('Disconnected');
});
chainChanged Payload Type
chainChangedemits a hex string (e.g.,'0x1','0x89'), not a decimal number- Never compare directly with decimal numbers:
chainId === 1will always be false - Convert if needed:
parseInt(chainId, 16)to get the decimal chain ID - This is a common source of bugs — always treat chainChanged payload as a hex string
SDK eventHandlers (Client Options)
- Configure event callbacks directly in client options via
eventHandlers:connect— same as EIP-1193 connectdisconnect— same as EIP-1193 disconnectaccountsChanged— same as EIP-1193 accountsChangedchainChanged— same as EIP-1193 chainChangeddisplayUri— fires with the connection URI string for QR code renderingconnectAndSign— fires with the signature result fromconnectAndSignflowconnectWith— fires with the result fromconnectWithflow
const client = await createEVMClient({
dapp: { name: 'My DApp' },
eventHandlers: {
accountsChanged: (accounts) => updateUI(accounts),
chainChanged: (chainId) => updateChain(chainId),
displayUri: (uri) => renderQrCode(uri),
},
});
display_uri Timing
display_urionly fires during the'connecting'state — between callingconnect()and the connection resolving- Register the
display_urilistener before callingconnect()— registering after may miss the event - The URI is a one-time-use pairing token; once used or expired, it cannot be reused
- On connection error, do not attempt to regenerate or reuse the QR — call
connect()again for a new URI - In non-headless mode, the SDK renders its own QR modal;
display_uriis mainly useful in headless mode
Multichain stateChanged Event
- The multichain core client emits
stateChangedwhenever the connection status changes - Listen via
client.on('stateChanged', (status) => ...)on the multichain client, wherestatusis aConnectionStatusstring - This is available on the multichain client (
createMultichainClient) and on the Solana client's public.coreproperty. The EVM client does not expose.core(it is private) — useclient.status/ provider events there
Transport Events
- For the Mobile Wallet Protocol (MWP) transport, the SDK attempts to resume an interrupted session — including a reconnection check when the browser tab regains focus — so you generally don't need to wire this up manually. This resumption logic is MWP-specific; the browser-extension transport does not use it.
- The provider's
disconnectevent carries no error payload — treat the event itself as the signal, and do not expect legacy json-rpc-engine codes (e.g.1013) from the connect-* packages
EIP-6963 Provider Announcement
- Since
@metamask/connect-evm2.0.0, the MMConnect-managed EIP-1193 provider is announced through EIP-6963 (eip6963:announceProvider) by default when native MetaMask has not already announced its own provider — so wallet-discovery UIs (RainbowKit, ConnectKit, Web3Modal, wagmi'sinjected/metaMaskdiscovery, etc.) can surface the MMConnect provider automatically - The auto-announce is suppressed when native MetaMask (extension) has already announced, and EIP-6963 extension detection is restricted to native MetaMask RDNS values so MMConnect announcements do not get mistaken for — or select — the browser-extension transport
- Pass
skipAutoAnnounce: truetocreateEVMClient()to opt out of the automatic announcement (e.g. when you want to control discovery manually or avoid a duplicate entry alongside another integration) - Call
client.announceProvider()to re-announce on demand — useful afterskipAutoAnnounce, or to re-emit in response to a lateeip6963:requestProviderevent from a discovery library that mounted after the SDK initialized
Cached State Methods
eth_accountsandeth_chainIdreturn locally cached state from the SDK rather than making RPC calls- The cached values are kept in sync via
accountsChangedandchainChangedevents, so they reflect the current state after connection - Use
client.getChainId()to get the current hex chain ID (returnsHex | undefined) - Use
client.getAccount()to get the current account address (returnsAddress | undefined) - Since
@metamask/connect-evm1.3.1, the intercepted EIP-1193 account requests return method-specific shapes that match the spec:provider.request({ method: 'eth_requestAccounts' })resolves to an accounts array (Address[]), andprovider.request({ method: 'eth_coinbase' })resolves to the currently selected account (Address), not the full accounts array. Do not destructureeth_coinbaseas an array (const [acct] = await provider.request({ method: 'eth_coinbase' })) — treat it as a single address string - Since
@metamask/connect-evm2.0.0, more intercepted EIP-1193 requests return spec-compatible values:provider.request({ method: 'wallet_requestPermissions' })resolves to the requested permissions array, while successfulwallet_switchEthereumChainandwallet_addEthereumChainrequests resolve tonull(per EIP-3326 / EIP-3085). Do not expect a truthy value back from a successful switch/add — branch on the absence of a thrown error, not on the resolved value
Client Status Property
- On the EVM client (
createEVMClient),client.statusisConnectEvmStatus:'connecting','connected', or'disconnected'(since@metamask/connect-evm0.11.0 it no longer proxiesMultichainClient.status) - On the multichain client (
createMultichainClient),client.statusis the 5-valueConnectionStatus:'loaded','pending','connecting','connected', or'disconnected' - Use this for UI state management instead of tracking connection state manually
Event Listener Best Practices
- Register event listeners before calling
connect()to catch all events including initial state - Remove listeners on component unmount to prevent memory leaks:
provider.removeListener('event', handler) - Do not register duplicate listeners — check if a listener is already registered before adding
- In React, use
useEffectcleanup to remove listeners:
useEffect(() => {
const provider = client.getProvider();
const handler = (accounts: string[]) => setAccounts(accounts);
provider.on('accountsChanged', handler);
return () => provider.removeListener('accountsChanged', handler);
}, [client]);
Multichain Session Lifecycle
Multichain session lifecycle rules — singleton merging, concurrent connect guard, session data shape, wallet_sessionChanged events, headless mode, timeouts, and permission handling
Singleton Merging
createMultichainClientis a singleton — calling it multiple times returns the same instance- On subsequent calls, new options merge into the existing instance
- The
dappobject from the first call is used for the client's lifetime — it is excluded from option merging entirely (laterdappvalues are ignored) api.supportedNetworksentries merge by spreading the new map over the old — new chains are added and existing keys are overwritten by later calls- Call
createMultichainClientonce at app startup and store the returned client reference
Concurrent Connect Guard
- Only one
connect()call can be active at a time over MetaMask Wallet Protocol (MWP) - Calling
connect()while a previous MWPconnect()is pending throws a plainError("Existing connection is pending. Please check your MetaMask Mobile app to continue.") with no numeric code — match on the message. (-32002is an extension-transport RPC-queue code, not an SDK error code) - Guard against double-clicks with a loading state or disable the connect button during connection
- The original pending
connect()promise will resolve once the user acts in MetaMask
Session Data Shape
- Multichain
connect()resolves with no value (Promise<void>) — session data arrives via thewallet_sessionChangedevent or on demand fromclient.provider.getSession() - Session data is
SessionData: scopes live undersessionScopes(e.g.,session.sessionScopes['eip155:1'].accounts), and accounts are CAIP-10 strings (eip155:1:0x...) sessionPropertiesmay be present — if empty, it isundefined(not an empty object)- Always null-check
sessionPropertiesbefore accessing its fields - Since
@metamask/connect-evm1.2.0, everywallet_createSessionrequest issued byconnect-evmattachessessionProperties: { 'eip1193-compatible': true }. Sessions established throughcreateEVMClientwill surface this flag on the resolved session, letting wallets and analytics consumers distinguish EIP-1193-style connections from pure Multichain API connections or other provider types (e.g. Solana Wallet Standard). Do not rely on it being present for sessions created directly via the multichain client
dapp.url Requirement
- In browser environments,
dapp.urlfalls back towindow.location.hrefif not specified - In Node.js and React Native,
dapp.urlis required — there is nowindow.locationto fall back to - Omitting
dapp.urlin non-browser environments throwsError: You must provide dapp urlduring client creation (in the browser it is auto-filled fromwindow.location, which is absent in Node.js / React Native)
Multichain Events
wallet_sessionChanged— fires when any part of the multichain session changes (accounts, scopes, permissions)- Listen on the multichain client directly with
client.on('wallet_sessionChanged', handler) - Payload contains the updated session object with all active scopes and accounts
- Fires on: initial connection, account changes, scope additions/removals, session restoration
// Payload is SessionData | undefined — iterate sessionScopes, not the payload itself
client.on('wallet_sessionChanged', (session) => {
for (const [scope, data] of Object.entries(session?.sessionScopes ?? {})) {
console.log(`Scope ${scope}:`, data.accounts); // CAIP-10 account IDs
}
});
Session Persistence and Resumption
- The SDK persists session state and attempts to resume on subsequent page loads
- Listen for
wallet_sessionChangedon startup to detect restored sessions - Do not call
connect()again if a session already exists — check session state first createEVMClientandcreateSolanaClientperform an initial session sync before returning, but session state should still be treated as event-driven- Do not assume a usable session exists unless your startup logic has observed the current session state or a
wallet_sessionChangedevent
Headless Mode
- Set
ui: { headless: true }to suppress the default QR code modal - Register a
display_urievent listener before callingconnect()to receive the connection URI display_urionly fires during the connecting phase — after connection or on error, it stops- On connection error in headless mode, do not try to regenerate the QR from the old URI — start a new
connect()call - The URI is a one-time-use pairing token
Timeouts
- Default request timeout is 60 seconds
- Mobile Wallet Protocol uses an extended 120 second connection timeout while waiting for user action in MetaMask Mobile
- Pending-session resumption waits about 10 seconds before giving up
- These are internal SDK timeouts — do not implement your own shorter timeouts that race against them
Bundle / Lazy-loaded Transport
- Since
@metamask/connect-multichain0.13.0, the MWP transport modules —@metamask/mobile-wallet-protocol-core,@metamask/mobile-wallet-protocol-dapp-client, andeciesjs— are dynamically imported only when MWP transport is actually used - Bundlers (webpack, Vite, Rollup, Metro) can now code-split the entire MWP + crypto dependency tree out of the main chunk for consumers who only use the browser-extension flow
- Do not statically import the MWP modules yourself in app code — that defeats the code-split and re-inflates the bundle
- Since
@metamask/connect-multichain0.14.0, the QR-code MWP flow (desktop web and Node.js) omits the initialwallet_createSessionrequest from the deeplink URI and sends it as a separate request after the wallet completes the MWP handshake. The result is a shorter deeplink URI and a less dense QR code. The native deeplink (non-QR MWP) flow used on mobile web and React Native is unchanged — no app-side action required
Permission Handling
- Use
connect(scopes, [], undefined, true)when you need a fresh permission prompt even if permissions already exist —forceRequestis the fourth positional argument - The multichain
connectsignature isconnect(scopes, caipAccountIds, sessionProperties?, forceRequest?)— all positional arguments, not an options object wallet_requestPermissionsitself does not take aforceRequestparameter; the SDK handles that throughconnect()- Without
forceRequest, the SDK may reuse an existing compatible session connect()internally handles the underlying permission request flow, so you rarely need to callwallet_requestPermissionsdirectly- For multichain,
connect(scopes, [])is the canonical way to request permissions for specific chains
Analytics
- The SDK emits dapp-side analytics events and attaches wallet-correlation metadata by default. To opt out, pass
analytics: { enabled: false }to the client factory — supported bycreateMultichainClient(@metamask/connect-multichain0.15.0+),createEVMClient(@metamask/connect-evm1.4.0+), andcreateSolanaClient(@metamask/connect-solana1.2.0+) - Setting
analytics.enabled: falseoncreateMultichainClientalso omits theanalytics.remote_session_idfield from connection metadata; on the EVM/Solana clients it disables dapp-side events and wallet-correlation metadata - To disable analytics at runtime after the client exists (rather than at construction), call
analytics.disable()(@metamask/analytics0.6.0+) — it stops event collection and clears any queued analytics events - Respect user privacy preferences (e.g. a Do-Not-Track or cookie-consent setting) by wiring them to
analytics.enabled/analytics.disable()rather than trying to intercept or block the network requests yourself
Solana Integration Constraints
Constraints and requirements for Solana integration with MetaMask Connect — wallet adapter config, CAIP-2 IDs, network support per platform, RPC routing, and platform limitations
Wallet Adapter Configuration
- The wallet name registered by
createSolanaClientis"MetaMask"(renamed from"MetaMask Connect"in@metamask/connect-solana1.0.0). Match on exactly"MetaMask"— do not branch on the old"MetaMask Connect"literal. - Since
@metamask/connect-solana1.0.0,createSolanaClientno longer announces its own wallet-standard provider if an injected Solana provider (e.g. the MetaMask browser extension) is already present. Treat the already-injected provider as MetaMask; your UI should not expect two wallet entries. WalletProvidermust receivewallets={[]}— MetaMask uses the wallet-standard auto-discovery protocol- Never manually add MetaMask to the wallets array — it will not be found and may cause duplicates
- Initialize
createSolanaClientearly in app startup, but it does not need to resolve before the firstWalletProviderrender - If your UI depends on MetaMask already being registered, gate that UI until
createSolanaClientresolves - Since
@metamask/connect-solana1.1.0,createSolanaClient()eagerly initializes the Solana wallet provider during creation — if the underlying multichain session already contains Solana scopes, the provider's accounts are populated by the time the client resolves. Apps no longer need to wait for a separatewallet_sessionChangedevent to read accounts on cold start - Since
@metamask/connect-solana1.1.0,getWallet()returns the same wallet instance on every call instead of constructing a new one. It is safe to cache the result in a module-level constant, ReactuseRef, oruseMemo— do not callgetWallet()on every render expecting a fresh instance
CAIP-2 Genesis Hash Identifiers
- Solana mainnet:
solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp - Solana devnet:
solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1 - These are genesis hash identifiers, not cluster URLs or chain IDs
- Always use the full CAIP-2 string as the scope in multichain
invokeMethodandconnect
Devnet and Testnet
- The SDK and the wallet-standard layer model three Solana scopes — mainnet (
solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp), devnet (solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1), and testnet (solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z) - Non-mainnet availability ultimately depends on the connected MetaMask build/version — don't assume a given cluster is present. Handle
connect()/invokeMethoderrors rather than treating devnet/testnet as guaranteed - For Solana read calls, point a
@solana/web3.jsConnectionat the matching cluster RPC (the SDK routes signing through the wallet, not reads)
RPC Routing
- All Solana methods route through the wallet — there is no RPC node fallback
- Unlike EVM (where read methods like
eth_getBalancego to Infura), every SolanainvokeMethodcall goes to MetaMask - This means every Solana call may prompt the user or require wallet availability
- For Solana read operations (balance, account info), use
@solana/web3.jsConnectiondirectly against an RPC endpoint
Disconnect Scopes Behavior
- On the Solana client (
createSolanaClient),disconnect()revokes only the Solana scopes (mainnet/devnet/testnet) — it does not touch EVM scopes. (Full-session teardown across all scopes is the multichain client'sdisconnect()with no arguments.) - On the multichain client (
createMultichainClient),disconnect(['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'])revokes only Solana mainnet — EVM scopes stay active - Disconnecting a Solana scope does not affect any active EVM connections
Chrome Android Bug
- There is a known issue with
@solana/wallet-adapter-reacton Chrome Android when used with the wallet-standard provider from@metamask/connect-solana - The connect monorepo carries a patch for the wallet-adapter behavior in that setup
- Treat Solana wallet-adapter flows on mobile Chrome as fragile until you verify them explicitly
- Test Solana flows on desktop Chrome and MetaMask browser extension wallet before targeting mobile
React Native Limitation
- The Solana wallet adapter (
@solana/wallet-adapter-react) is not supported in React Native - For Solana in React Native, use the multichain client (
createMultichainClient) withinvokeMethoddirectly - Do not attempt to import
@solana/wallet-adapter-reactor@solana/wallet-adapter-react-uiin RN — they depend on browser APIs
React Native Polyfills for MetaMask Connect
Required polyfills and configuration for MetaMask Connect SDK in React Native — import order, Buffer, window, Event/CustomEvent, metro config, and persistence
Per-Package Polyfill Requirements
Different integrations need different polyfills. Do not blindly copy the full set:
| Polyfill | connect-evm / connect-solana (standalone) | + wagmi |
|---|---|---|
react-native-get-random-values |
RN < 0.72 only (see below) | RN < 0.72 only |
Buffer |
Safety net only (self-polyfilled by connect-multichain) | Safety net only |
window object |
Required for correct deeplink/platform detection | Required |
Event |
Not required | Required (wagmi uses DOM events) |
CustomEvent |
Not required | Required (wagmi uses DOM events) |
Import Order (Critical)
// Entry file (_layout.tsx / index.js) — order is critical
import 'react-native-get-random-values'; // MUST be first (if used)
import './polyfills'; // window shim, and Event/CustomEvent if using wagmi
Incorrect order causes crypto.getRandomValues is not a function at runtime.
react-native-get-random-values
- Required only for React Native < 0.72 — Hermes 0.72+ exposes
globalThis.crypto.getRandomValuesnatively - Still recommended as an explicit safety net — especially if any dependency has its own minimum RN version assumptions
- Must be the very first import in the entry file, before anything that touches crypto
Buffer Polyfill
@metamask/connect-multichainself-polyfillsBuffervia its React Native entry point — not needed for the SDK itself- Still recommended to set
global.Buffer = Bufferinpolyfills.tsas a safety net for peer deps (e.g.eciesjs,@solana/web3.js) that may load before connect-multichain - Install:
npm install buffer
window Object Polyfill
- Required for correct platform and deeplink behaviour —
getPlatformType()in connect-multichain inspectswindowandglobal.navigator.productto decide between the deeplink path and the install-modal path - All
window.*accesses inside the SDK are guarded, so code will not crash without it, butisSecure()returns the wrong value and deeplinks will not trigger - Provide at minimum:
location,addEventListener,removeEventListener,dispatchEvent
Event and CustomEvent Polyfills
- Not required by the connect-* packages themselves — the SDK uses
eventemitter3for all internal eventing; DOMEvent/CustomEventare never constructed in React Native code paths - Required when using wagmi — wagmi core dispatches DOM events internally
- Add only if your integration uses wagmi:
class EventPolyfill { /* ... */ }
class CustomEventPolyfill extends EventPolyfill { detail: any; /* ... */ }
global.Event = EventPolyfill as any;
global.CustomEvent = CustomEventPolyfill as any;
Metro extraNodeModules
- The MetaMask Connect SDK has transitive dependencies on Node.js built-in modules
- Metro cannot resolve them without explicit shims in
metro.config.js streammust map toreadable-stream(notstream-browserify) — it is the only built-in that needs a real implementation- Map every other referenced built-in to an empty stub module (
module.exports = {};) — they are referenced by transitive deps but never called at runtime in React Native (this matches the SDK's own react-native-playground):
// metro.config.js
const path = require('path');
const emptyModule = path.resolve(__dirname, 'src', 'empty-module.js'); // module.exports = {};
resolver: {
extraNodeModules: {
stream: require.resolve('readable-stream'),
crypto: emptyModule,
http: emptyModule,
https: emptyModule,
net: emptyModule,
tls: emptyModule,
zlib: emptyModule,
os: emptyModule,
dns: emptyModule,
assert: emptyModule,
url: emptyModule,
path: emptyModule,
fs: emptyModule,
},
}
- Only
readable-streamneeds to be installed — do not installreact-native-crypto,@tradle/react-native-http,https-browserify, oros-browserify; they are obsolete for this SDK
preferredOpenLink (Required)
mobile.preferredOpenLinkmust be set in React Native for deeplinks to open MetaMask Mobile- Pass:
(deeplink: string) => Linking.openURL(deeplink) - Without this, connection attempts via MWP will hang — no deeplink is triggered
Async Storage for Persistence
- Browser localStorage is not available in React Native
- Use
@react-native-async-storage/async-storagefor session persistence - With wagmi: use
createAsyncStoragePersisterfrom@tanstack/query-async-storage-persister - Without wagmi: the MetaMask Connect SDK handles persistence internally when AsyncStorage is provided
MetaMask Connect Testing Patterns
Testing patterns for MetaMask Connect SDK — provider mocking, client mocking, singleton cleanup, and event testing
Provider Mocking
- Mock the EIP-1193 provider's request method for unit tests
- Create a mock provider factory that returns controlled responses
- Example:
const mockProvider = { request: vi.fn(), on: vi.fn(), removeListener: vi.fn() } - Mock different responses for different methods (eth_accounts, eth_chainId, etc.)
Client Mocking
- Mock createEVMClient to return a controlled client object
- Mock client.connect(), client.disconnect(), client.getProvider(), client.switchChain()
- For multichain: mock createMultichainClient, client.invokeMethod(), client.on()
Singleton Cleanup
- createMultichainClient is a singleton — tests that create clients will share state
- Clear or reset the singleton between test runs
- Use beforeEach/afterEach to ensure clean state
Test Networks
- Use Sepolia (0xaa36a7) for E2E tests, never mainnet
- For Solana E2E: use devnet — supported in the MetaMask browser extension (mobile supports mainnet only)
- Mock RPC responses for unit tests; use real RPCs only for integration tests
Async Client Initialization
- createEVMClient and createMultichainClient are async — tests must await them
- In React testing, await the client before rendering components that depend on it
- Use act() wrapper for React state updates triggered by SDK events
Error Simulation
- Test user rejection: throw { code: 4001, message: 'User rejected' }
- Test pending connection: throw { code: -32002, message: 'Already pending' }
- Test network errors: simulate RPC failures
- Test disconnect scenarios
Event Testing
- Test that components react to accountsChanged, chainChanged events
- Simulate events by calling the mock provider's event handlers
- Test display_uri event handling for headless mode
Solana Testing
- Mock wallet-standard wallet object
- Mock signMessage, signAndSendTransaction features
- Test wallet discovery with mocked wallet registry