|
| 1 | +# MetaMask Connect — Event Handling |
| 2 | + |
| 3 | +EVM provider and `connect-evm` event handling: EIP-1193 events, SDK `eventHandlers`, payload types, `display_uri` timing, EIP-6963 announcement, cached state, and listener best practices. For multichain `wallet_sessionChanged` see [multichain.md](multichain.md); for always-on core guardrails see [conventions.md](conventions.md). |
| 4 | + |
| 5 | +## Contents |
| 6 | + |
| 7 | +- [EIP-1193 Events (EVM Provider)](#eip-1193-events-evm-provider) |
| 8 | +- [chainChanged Payload Type](#chainchanged-payload-type) |
| 9 | +- [SDK eventHandlers (Client Options)](#sdk-eventhandlers-client-options) |
| 10 | +- [display_uri Timing](#display_uri-timing) |
| 11 | +- [Multichain stateChanged Event](#multichain-statechanged-event) |
| 12 | +- [Transport Events](#transport-events) |
| 13 | +- [EIP-6963 Provider Announcement](#eip-6963-provider-announcement) |
| 14 | +- [Cached State Methods](#cached-state-methods) |
| 15 | +- [Client Status Property](#client-status-property) |
| 16 | +- [Event Listener Best Practices](#event-listener-best-practices) |
| 17 | + |
| 18 | +## EIP-1193 Events (EVM Provider) |
| 19 | + |
| 20 | +- **`connect`** — fired when the provider establishes a connection; payload: `{ chainId: Hex; accounts: Address[] }` |
| 21 | +- **`disconnect`** — fired when the provider loses connection; **no payload** |
| 22 | +- **`accountsChanged`** — fired when the user's accounts change; payload: `string[]` (array of addresses) |
| 23 | +- **`chainChanged`** — fired when the active chain changes; payload: `string` (**hex** chain ID, not decimal) |
| 24 | +- **`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 |
| 25 | + |
| 26 | +```typescript |
| 27 | +const provider = client.getProvider(); |
| 28 | + |
| 29 | +provider.on('accountsChanged', (accounts: string[]) => { |
| 30 | + console.log('New accounts:', accounts); |
| 31 | +}); |
| 32 | + |
| 33 | +provider.on('chainChanged', (chainId: string) => { |
| 34 | + // chainId is HEX (e.g., '0x1'), NOT decimal |
| 35 | + console.log('New chain:', chainId); |
| 36 | +}); |
| 37 | + |
| 38 | +provider.on( |
| 39 | + 'connect', |
| 40 | + ({ chainId, accounts }: { chainId: string; accounts: string[] }) => { |
| 41 | + console.log('Connected to chain:', chainId, 'accounts:', accounts); |
| 42 | + }, |
| 43 | +); |
| 44 | + |
| 45 | +provider.on('disconnect', () => { |
| 46 | + // No payload — the event itself is the signal |
| 47 | + console.log('Disconnected'); |
| 48 | +}); |
| 49 | +``` |
| 50 | + |
| 51 | +## chainChanged Payload Type |
| 52 | + |
| 53 | +- `chainChanged` emits a **hex string** (e.g., `'0x1'`, `'0x89'`), **not a decimal number** |
| 54 | +- Never compare directly with decimal numbers: `chainId === 1` will always be false |
| 55 | +- Convert if needed: `parseInt(chainId, 16)` to get the decimal chain ID |
| 56 | +- This is a common source of bugs — always treat chainChanged payload as a hex string |
| 57 | + |
| 58 | +## SDK eventHandlers (Client Options) |
| 59 | + |
| 60 | +- Configure event callbacks directly in client options via `eventHandlers`: |
| 61 | + - `connect` — same as EIP-1193 connect |
| 62 | + - `disconnect` — same as EIP-1193 disconnect |
| 63 | + - `accountsChanged` — same as EIP-1193 accountsChanged |
| 64 | + - `chainChanged` — same as EIP-1193 chainChanged |
| 65 | + - `displayUri` — fires with the connection URI string for QR code rendering |
| 66 | + - `connectAndSign` — fires with the signature result from `connectAndSign` flow |
| 67 | + - `connectWith` — fires with the result from `connectWith` flow |
| 68 | + |
| 69 | +```typescript |
| 70 | +const client = await createEVMClient({ |
| 71 | + dapp: { name: 'My DApp' }, |
| 72 | + eventHandlers: { |
| 73 | + accountsChanged: (accounts) => updateUI(accounts), |
| 74 | + chainChanged: (chainId) => updateChain(chainId), |
| 75 | + displayUri: (uri) => renderQrCode(uri), |
| 76 | + }, |
| 77 | +}); |
| 78 | +``` |
| 79 | + |
| 80 | +## display_uri Timing |
| 81 | + |
| 82 | +- `display_uri` only fires during the `'connecting'` state — between calling `connect()` and the connection resolving |
| 83 | +- Register the `display_uri` listener **before** calling `connect()` — registering after may miss the event |
| 84 | +- The URI is a one-time-use pairing token; once used or expired, it cannot be reused |
| 85 | +- On connection error, do not attempt to regenerate or reuse the QR — call `connect()` again for a new URI |
| 86 | +- In non-headless mode, the SDK renders its own QR modal; `display_uri` is mainly useful in headless mode |
| 87 | + |
| 88 | +## Multichain stateChanged Event |
| 89 | + |
| 90 | +- The multichain core client emits `stateChanged` whenever the connection status changes |
| 91 | +- Listen via `client.on('stateChanged', (status) => ...)` on the multichain client, where `status` is a `ConnectionStatus` string |
| 92 | +- This is available on the multichain client (`createMultichainClient`) and on the Solana client's public `.core` property. The EVM client does **not** expose `.core` (it is private) — use `client.status` / provider events there |
| 93 | + |
| 94 | +## Transport Events |
| 95 | + |
| 96 | +- 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. |
| 97 | +- The provider's `disconnect` event 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 |
| 98 | + |
| 99 | +## EIP-6963 Provider Announcement |
| 100 | + |
| 101 | +- Since `@metamask/connect-evm` 2.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's `injected`/`metaMask` discovery, etc.) can surface the MMConnect provider automatically |
| 102 | +- 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 |
| 103 | +- Pass `skipAutoAnnounce: true` to `createEVMClient()` to opt out of the automatic announcement (e.g. when you want to control discovery manually or avoid a duplicate entry alongside another integration) |
| 104 | +- Call `client.announceProvider()` to re-announce on demand — useful after `skipAutoAnnounce`, or to re-emit in response to a late `eip6963:requestProvider` event from a discovery library that mounted after the SDK initialized |
| 105 | + |
| 106 | +## Cached State Methods |
| 107 | + |
| 108 | +- `eth_accounts` and `eth_chainId` return locally cached state from the SDK rather than making RPC calls |
| 109 | +- The cached values are kept in sync via `accountsChanged` and `chainChanged` events, so they reflect the current state after connection |
| 110 | +- Use `client.getChainId()` to get the current hex chain ID (returns `Hex | undefined`) |
| 111 | +- Use `client.getAccount()` to get the current account address (returns `Address | undefined`) |
| 112 | +- Since `@metamask/connect-evm` 1.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[]`), and `provider.request({ method: 'eth_coinbase' })` resolves to the **currently selected account** (`Address`), **not** the full accounts array. Do not destructure `eth_coinbase` as an array (`const [acct] = await provider.request({ method: 'eth_coinbase' })`) — treat it as a single address string |
| 113 | +- Since `@metamask/connect-evm` 2.0.0, more intercepted EIP-1193 requests return spec-compatible values: `provider.request({ method: 'wallet_requestPermissions' })` resolves to the **requested permissions** array, while successful `wallet_switchEthereumChain` and `wallet_addEthereumChain` requests resolve to **`null`** (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 |
| 114 | + |
| 115 | +## Client Status Property |
| 116 | + |
| 117 | +- On the EVM client (`createEVMClient`), `client.status` is `ConnectEvmStatus`: `'connecting'`, `'connected'`, or `'disconnected'` (since `@metamask/connect-evm` 0.11.0 it no longer proxies `MultichainClient.status`) |
| 118 | +- On the multichain client (`createMultichainClient`), `client.status` is the 5-value `ConnectionStatus`: `'loaded'`, `'pending'`, `'connecting'`, `'connected'`, or `'disconnected'` |
| 119 | +- Use this for UI state management instead of tracking connection state manually |
| 120 | + |
| 121 | +## Event Listener Best Practices |
| 122 | + |
| 123 | +- Register event listeners before calling `connect()` to catch all events including initial state |
| 124 | +- Remove listeners on component unmount to prevent memory leaks: `provider.removeListener('event', handler)` |
| 125 | +- Do not register duplicate listeners — check if a listener is already registered before adding |
| 126 | +- In React, use `useEffect` cleanup to remove listeners: |
| 127 | + |
| 128 | +```typescript |
| 129 | +useEffect(() => { |
| 130 | + const provider = client.getProvider(); |
| 131 | + const handler = (accounts: string[]) => setAccounts(accounts); |
| 132 | + provider.on('accountsChanged', handler); |
| 133 | + return () => provider.removeListener('accountsChanged', handler); |
| 134 | +}, [client]); |
| 135 | +``` |
0 commit comments