From 18157ea7cd6959333cc6f6030b52480e1a42c566 Mon Sep 17 00:00:00 2001 From: Alex Donesky Date: Wed, 17 Jun 2026 09:56:00 -0500 Subject: [PATCH 01/10] feat: add metamask-connect agent skill (skills.sh) Colocate the MetaMask Connect SDK agent skill in the SDK repo so it versions with the code and is installable via `npx skills add MetaMask/connect-monorepo`, with skills.sh install analytics. Single progressive-disclosure skill (Agent Skills format), mirroring MetaMask/smart-accounts-kit#264: a routing SKILL.md points into references/ (conventions, troubleshooting) and workflows/ (per-stack setup, sign/send for EVM + Solana, multichain invokeMethod, migration). Ported from MetaMask/skills#50 (now closed); source-verified against the published @metamask/connect-* packages. --- skills/metamask-connect/SKILL.md | 93 +++ .../references/conventions.md | 584 ++++++++++++++++++ .../references/troubleshooting.md | 492 +++++++++++++++ .../workflows/migrate-from-sdk.md | 379 ++++++++++++ .../workflows/migrate-wagmi-connector.md | 277 +++++++++ .../workflows/multichain-evm-operations.md | 218 +++++++ .../workflows/multichain-solana-operations.md | 244 ++++++++ .../workflows/send-evm-transaction.md | 254 ++++++++ .../workflows/send-solana-transaction.md | 291 +++++++++ .../workflows/setup-evm-browser.md | 299 +++++++++ .../workflows/setup-evm-react-native.md | 332 ++++++++++ .../workflows/setup-evm-react.md | 300 +++++++++ .../workflows/setup-multichain.md | 301 +++++++++ .../workflows/setup-solana-browser.md | 267 ++++++++ .../workflows/setup-solana-react-native.md | 399 ++++++++++++ .../workflows/setup-solana-react.md | 198 ++++++ .../workflows/setup-wagmi-connector.md | 303 +++++++++ .../metamask-connect/workflows/setup-wagmi.md | 252 ++++++++ .../workflows/sign-evm-message.md | 228 +++++++ .../workflows/sign-solana-message.md | 156 +++++ 20 files changed, 5867 insertions(+) create mode 100644 skills/metamask-connect/SKILL.md create mode 100644 skills/metamask-connect/references/conventions.md create mode 100644 skills/metamask-connect/references/troubleshooting.md create mode 100644 skills/metamask-connect/workflows/migrate-from-sdk.md create mode 100644 skills/metamask-connect/workflows/migrate-wagmi-connector.md create mode 100644 skills/metamask-connect/workflows/multichain-evm-operations.md create mode 100644 skills/metamask-connect/workflows/multichain-solana-operations.md create mode 100644 skills/metamask-connect/workflows/send-evm-transaction.md create mode 100644 skills/metamask-connect/workflows/send-solana-transaction.md create mode 100644 skills/metamask-connect/workflows/setup-evm-browser.md create mode 100644 skills/metamask-connect/workflows/setup-evm-react-native.md create mode 100644 skills/metamask-connect/workflows/setup-evm-react.md create mode 100644 skills/metamask-connect/workflows/setup-multichain.md create mode 100644 skills/metamask-connect/workflows/setup-solana-browser.md create mode 100644 skills/metamask-connect/workflows/setup-solana-react-native.md create mode 100644 skills/metamask-connect/workflows/setup-solana-react.md create mode 100644 skills/metamask-connect/workflows/setup-wagmi-connector.md create mode 100644 skills/metamask-connect/workflows/setup-wagmi.md create mode 100644 skills/metamask-connect/workflows/sign-evm-message.md create mode 100644 skills/metamask-connect/workflows/sign-solana-message.md diff --git a/skills/metamask-connect/SKILL.md b/skills/metamask-connect/SKILL.md new file mode 100644 index 00000000..7af11c56 --- /dev/null +++ b/skills/metamask-connect/SKILL.md @@ -0,0 +1,93 @@ +--- +name: metamask-connect +description: Build dApps that integrate MetaMask via the MetaMask Connect SDK — EVM (@metamask/connect-evm), Solana (@metamask/connect-solana), and multichain (@metamask/connect-multichain), plus the wagmi metaMask() connector. Covers client setup across browser/React/React Native, connecting, signing messages, sending transactions, multichain invokeMethod across CAIP-2 scopes, migrating from @metamask/sdk, and troubleshooting connection/polyfill issues. +--- + +# MetaMask Connect SDK + +## When to use + +- You want to set up a dApp's MetaMask integration — EVM, Solana, or both (multichain) — in vanilla browser JS/TS, React, or React Native +- You want to connect/disconnect, manage the provider and session state, or switch chains +- You want to sign messages (`personal_sign`, `eth_signTypedData_v4`, Solana `signMessage`) — e.g. Sign-In With Ethereum or nonce auth +- You want to send transactions (`eth_sendTransaction`, Solana `sendTransaction` / `signAndSendTransaction`) +- You want to operate across chains through the multichain client's `invokeMethod` +- You want to use or migrate to the wagmi `metaMask()` connector +- You want to migrate an existing `@metamask/sdk` integration to the Connect SDK +- You need to diagnose connection failures, React Native polyfill errors, or QR/deeplink issues + +## Installation + +Pick the client for your integration: + +| You need | Package | Factory | +| --------------------------------- | ---------------------------------------------------------------------- | ------------------------ | +| EVM only | `@metamask/connect-evm` | `createEVMClient` | +| Solana only | `@metamask/connect-solana` | `createSolanaClient` | +| EVM **and** Solana in one session | `@metamask/connect-multichain` | `createMultichainClient` | +| You already use wagmi | wagmi `metaMask()` connector (needs `@metamask/connect-evm` as a peer) | — | + +## Always-on conventions + +Before writing or reviewing **any** MetaMask Connect code, read [references/conventions.md](references/conventions.md) — hex chain IDs, `supportedNetworks` validation, EIP-1193 provider events, multichain session lifecycle, Solana constraints, React Native polyfills, and testing patterns. Apply it alongside every workflow below. + +## Set up (choose your stack) + +| Building | Workflow | +| --------------------------------- | -------------------------------------------------------------------------------- | +| EVM dApp — vanilla browser JS/TS | [workflows/setup-evm-browser.md](workflows/setup-evm-browser.md) | +| EVM dApp — React | [workflows/setup-evm-react.md](workflows/setup-evm-react.md) | +| EVM dApp — React Native | [workflows/setup-evm-react-native.md](workflows/setup-evm-react-native.md) | +| Solana dApp — vanilla browser | [workflows/setup-solana-browser.md](workflows/setup-solana-browser.md) | +| Solana dApp — React | [workflows/setup-solana-react.md](workflows/setup-solana-react.md) | +| Solana dApp — React Native | [workflows/setup-solana-react-native.md](workflows/setup-solana-react-native.md) | +| EVM + Solana (multichain) | [workflows/setup-multichain.md](workflows/setup-multichain.md) | +| wagmi app | [workflows/setup-wagmi.md](workflows/setup-wagmi.md) | +| wagmi + the connect-evm connector | [workflows/setup-wagmi-connector.md](workflows/setup-wagmi-connector.md) | + +## Sign & send (single-chain clients) + +Use these with a directly-created EVM or Solana client. If you set up the **multichain** client, sign/send via `invokeMethod` instead — see the multichain workflows below. + +| Task | Workflow | +| ---------------------------------------------------------------------- | ---------------------------------------------------------------------------- | +| Sign — EVM (`personal_sign`, `eth_signTypedData_v4`, `connectAndSign`) | [workflows/sign-evm-message.md](workflows/sign-evm-message.md) | +| Sign — Solana (wallet-standard `signMessage`) | [workflows/sign-solana-message.md](workflows/sign-solana-message.md) | +| Send — EVM (`eth_sendTransaction`, gas, receipts, `connectWith`) | [workflows/send-evm-transaction.md](workflows/send-evm-transaction.md) | +| Send — Solana (`sendTransaction` / `signAndSendTransaction`) | [workflows/send-solana-transaction.md](workflows/send-solana-transaction.md) | + +## Multichain operations (`invokeMethod` across CAIP-2 scopes) + +Use these after `createMultichainClient` to sign or send across CAIP-2 scopes. + +| Ecosystem | Workflow | +| --------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| EVM scopes (`eth_sendTransaction`, `personal_sign`, `eth_signTypedData_v4`) | [workflows/multichain-evm-operations.md](workflows/multichain-evm-operations.md) | +| Solana scopes (`signTransaction`, `signAndSendTransaction`, `signMessage`) | [workflows/multichain-solana-operations.md](workflows/multichain-solana-operations.md) | + +## Migrate + +| Migrating from | Workflow | +| ----------------------------------------------------- | ---------------------------------------------------------------------------- | +| `@metamask/sdk` → `@metamask/connect-*` | [workflows/migrate-from-sdk.md](workflows/migrate-from-sdk.md) | +| wagmi app → the new `@metamask/connect-evm` connector | [workflows/migrate-wagmi-connector.md](workflows/migrate-wagmi-connector.md) | + +## Troubleshooting + +When a connection hangs/fails, a React Native app crashes on a missing polyfill, QR codes or deeplinks don't work, the Solana wallet adapter doesn't detect MetaMask, or a session is lost after reload — see [references/troubleshooting.md](references/troubleshooting.md) for a symptom → cause → fix index and a diagnostic checklist. + +## Important notes + +These are the highest-value guardrails; [references/conventions.md](references/conventions.md) has the full, source-verified set. + +- EVM chain IDs are **hex strings** (`'0x1'`, not `1` or `'1'`); CAIP-2 scopes use **decimal** (`eip155:1`). +- Every chain the dApp touches must be in `api.supportedNetworks` with a reachable RPC URL — the check runs in the provider's `request()` path, not in `connect()`. +- The multichain core is a **singleton** — create clients once at startup, never inside a React render. +- Handle EIP-1193 code `4001` (user rejected) and `-32002` (extension request pending) in `catch` blocks; multichain `invokeMethod` errors arrive wrapped in `RPCInvokeMethodErr` (original code on `rpcCode`). +- React Native needs polyfills (a `window` shim always; `Event`/`CustomEvent` only when also using wagmi; `react-native-get-random-values` as the first import) plus metro `extraNodeModules` shims (`stream` → `readable-stream`, the rest → empty stubs). + +## Resources + +- NPM: `@metamask/connect-evm`, `@metamask/connect-solana`, `@metamask/connect-multichain` +- Source plugin: https://github.com/MetaMask/metamask-connect-cursor-plugin +- Provenance: generated from that plugin's `skills/` and always-on `rules/`, source-verified against the published `@metamask/connect-*` packages. diff --git a/skills/metamask-connect/references/conventions.md b/skills/metamask-connect/references/conventions.md new file mode 100644 index 00000000..82b657e8 --- /dev/null +++ b/skills/metamask-connect/references/conventions.md @@ -0,0 +1,584 @@ +# MetaMask Connect — Conventions & Guardrails + +Always-on guardrails for the MetaMask Connect SDK, distilled from the [MetaMask Connect Cursor plugin](https://github.com/MetaMask/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/wagmi` exists unless your package version exports it +- `@metamask/connect-multichain` is a **regular dependency** of both `@metamask/connect-evm` and `@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-multichain` resolutions; if you do depend on it directly (e.g. to use `createMultichainClient`), use `^1.0.0` — it is a stable 1.x package following strict semver + +## Required Configuration + +- `dapp.name` is always required — it appears in the MetaMask connection prompt +- `dapp.url` is required in Node.js and React Native environments (no `window.location` available) +- `dapp.url` in browser can default to `window.location.href` but explicit is safer +- `dapp.iconUrl` is optional — displayed in MetaMask connection UI +- `dapp.base64Icon` is an alternative to `iconUrl` — 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.supportedNetworks` with a reachable RPC URL +- Use `getInfuraRpcUrls({ infuraApiKey: 'API_KEY', chainIds?: Hex[] })` to populate common EVM chains — it returns a hex-keyed map for `createEVMClient` +- Use `getInfuraRpcUrls({ infuraApiKey: 'API_KEY', caipChainIds?: string[] })` to populate CAIP-2 chains for `createMultichainClient` +- Use `getInfuraRpcUrls({ infuraApiKey: 'API_KEY', networks: SolanaNetwork[] })` from `@metamask/connect-solana` to populate a network-name-keyed map for `createSolanaClient` — `networks` is required +- Chain `0x1` (Ethereum mainnet) is auto-included in the EVM `connect()` permission request if not specified — but it is **not** auto-added to `supportedNetworks`, which must list every chain explicitly +- Making an RPC request whose active chain is missing from `supportedNetworks` throws "not configured in supportedNetworks" (the check runs in the provider's `request()` path, not in `connect()`) + +## Singleton Behavior + +- `createMultichainClient` is the singleton shared core instance +- `createEVMClient` and `createSolanaClient` create 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 `dapp` object from the first call and does not overwrite it later +- Never call `create*Client` inside a React component render — call it once at app startup +- Do not wrap client creation in `useEffect` or 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 as `err.code`; on the multichain client it appears as `err.rpcCode` (see below) +- Code `-32002` ("request already pending") comes from the **extension transport only** — multichain MWP concurrent `connect()` instead throws a plain `Error` ("Existing connection is pending...") with no numeric code +- Wrap all `connect()`, `invokeMethod()`, and signing calls in try/catch +- Multichain `invokeMethod()` errors are wrapped in `RPCInvokeMethodErr` (its own `code` is `53`); the wallet's original code/message/data are preserved on `rpcCode` / `rpcMessage` / `rpcData`: + + ```typescript + 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 no `ProtocolError`/`StorageError`/`RpcError` exports.) + +## Connection State + +- Check connection state before making signing requests +- Listen for `wallet_sessionChanged` to 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 the `eip155:*` 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: is not supported by Metamask Connect/EVM` (they are not silently ignored) +- Since `@metamask/connect-evm` 2.0.0, `wallet_requestPermissions` resolves to a spec-shaped requested-permissions array — but `connect()` 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'` not `1` or `'1'` +- All `chainIds` arrays, `supportedNetworks` keys, and `switchChain` parameters 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:` — always uses decimal, not hex +- EVM RPC / EIP-1193 format uses hex strings (`0x1`) +- Multichain `invokeMethod` scope uses CAIP-2 (`eip155:1`) +- EVM client `connect({ chainIds })` uses hex strings (`['0x1']`) +- Convert: hex `0x89` → decimal `137` → CAIP-2 `eip155:137` + +## Auto-Included Chain + +- `0x1` (Ethereum mainnet) is automatically included in the EVM client's `connect()` **permission request** even if you don't pass it in `chainIds` +- It is **not** injected into `api.supportedNetworks` — that map must explicitly contain every chain you use (including mainnet), and `createEVMClient` throws 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-evm` as a peer dependency +- Use `getInfuraRpcUrls({ infuraApiKey: 'API_KEY', chainIds?: Hex[] })` from `@metamask/connect-evm` to populate `supportedNetworks` — returns a hex-chain-ID-keyed map of Infura RPC URLs (e.g. `{ '0x1': 'https://...', '0x89': 'https://...' }`); `chainIds` is optional and filters to specific hex chain IDs +- The multichain equivalent in `@metamask/connect-multichain` is `getInfuraRpcUrls({ 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_switchEthereumChain` can fail +- Pass `chainConfiguration` directly to `client.switchChain()` as the `wallet_addEthereumChain` fallback payload +- In wagmi flows, the connector passes the same fallback config through to the underlying SDK `switchChain()` call +- Since `@metamask/connect-evm` 1.2.0, calling `switchChain({ chainId })` without a `chainConfiguration` now surfaces the wallet's **original** `Unrecognized chain ID` error (EIP-1193 code `4902`) instead of the previous `No chain configuration found.` wrapper. Catch the raw code in your `catch` block and either retry with a `chainConfiguration` fallback, call `wallet_addEthereumChain` explicitly, or prompt the user to add the chain — do not pattern-match on the legacy `"No chain configuration found"` message string +- Since `@metamask/connect-evm` 2.0.0, MWP-backed (Mobile Wallet Protocol) EIP-1193 requests reject with the wallet's error consistently with the default transport, so `switchChain()` no longer inspects returned error payloads — wallet errors (including `4902`) always arrive as a **rejected promise**. Handle switch-chain failures purely in `catch`; do not check for an error object in the resolved value of `switchChain()` or a `provider.request({ method: 'wallet_switchEthereumChain' })` call + +## Validation Error + +- Making an RPC request whose **active** chain's CAIP scope is missing from `supportedNetworks` throws `Chain eip155: 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** in `connect()`. `connect()` only validates that `chainIds` is a non-empty array, and `wallet_switchEthereumChain` is forwarded to the wallet (it is not gated by `supportedNetworks`). +- Fix: add every chain the dApp reads from to `supportedNetworks` with 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 payload** +- **`accountsChanged`** — 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 + +```typescript +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 + +- `chainChanged` emits a **hex string** (e.g., `'0x1'`, `'0x89'`), **not a decimal number** +- Never compare directly with decimal numbers: `chainId === 1` will 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 connect + - `disconnect` — same as EIP-1193 disconnect + - `accountsChanged` — same as EIP-1193 accountsChanged + - `chainChanged` — same as EIP-1193 chainChanged + - `displayUri` — fires with the connection URI string for QR code rendering + - `connectAndSign` — fires with the signature result from `connectAndSign` flow + - `connectWith` — fires with the result from `connectWith` flow + +```typescript +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_uri` only fires during the `'connecting'` state — between calling `connect()` and the connection resolving +- Register the `display_uri` listener **before** calling `connect()` — 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_uri` is mainly useful in headless mode + +## Multichain stateChanged Event + +- The multichain core client emits `stateChanged` whenever the connection status changes +- Listen via `client.on('stateChanged', (status) => ...)` on the multichain client, where `status` is a `ConnectionStatus` string +- 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 + +## 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 `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 + +## EIP-6963 Provider Announcement + +- 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 +- 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: 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) +- 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 + +## Cached State Methods + +- `eth_accounts` and `eth_chainId` return locally cached state from the SDK rather than making RPC calls +- The cached values are kept in sync via `accountsChanged` and `chainChanged` events, so they reflect the current state after connection +- Use `client.getChainId()` to get the current hex chain ID (returns `Hex | undefined`) +- Use `client.getAccount()` to get the current account address (returns `Address | undefined`) +- 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 +- 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 + +## Client Status Property + +- 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`) +- On the multichain client (`createMultichainClient`), `client.status` is the 5-value `ConnectionStatus`: `'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 `useEffect` cleanup to remove listeners: + +```typescript +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 + +- `createMultichainClient` is a singleton — calling it multiple times returns the same instance +- On subsequent calls, new options merge into the existing instance +- The `dapp` object from the first call is used for the client's lifetime — it is **excluded from option merging** entirely (later `dapp` values are ignored) +- `api.supportedNetworks` entries merge by spreading the new map over the old — new chains are added and **existing keys are overwritten** by later calls +- Call `createMultichainClient` once 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 MWP `connect()` is pending throws a plain `Error` ("Existing connection is pending. Please check your MetaMask Mobile app to continue.") with **no numeric code** — match on the message. (`-32002` is 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`) — session data arrives via the `wallet_sessionChanged` event or on demand from `client.provider.getSession()` +- Session data is `SessionData`: scopes live under `sessionScopes` (e.g., `session.sessionScopes['eip155:1'].accounts`), and accounts are CAIP-10 strings (`eip155:1:0x...`) +- `sessionProperties` may be present — if empty, it is `undefined` (not an empty object) +- Always null-check `sessionProperties` before accessing its fields +- Since `@metamask/connect-evm` 1.2.0, every `wallet_createSession` request issued by `connect-evm` attaches `sessionProperties: { 'eip1193-compatible': true }`. Sessions established through `createEVMClient` will 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.url` falls back to `window.location.href` if not specified +- In Node.js and React Native, `dapp.url` is **required** — there is no `window.location` to fall back to +- Omitting `dapp.url` in non-browser environments throws `Error: You must provide dapp url` during client creation (in the browser it is auto-filled from `window.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 + +```typescript +// 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_sessionChanged` on startup to detect restored sessions +- Do not call `connect()` again if a session already exists — check session state first +- `createEVMClient` and `createSolanaClient` perform 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_sessionChanged` event + +## Headless Mode + +- Set `ui: { headless: true }` to suppress the default QR code modal +- Register a `display_uri` event listener **before** calling `connect()` to receive the connection URI +- `display_uri` only 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-multichain` 0.13.0, the MWP transport modules — `@metamask/mobile-wallet-protocol-core`, `@metamask/mobile-wallet-protocol-dapp-client`, and `eciesjs` — 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-multichain` 0.14.0, the QR-code MWP flow (desktop web and Node.js) omits the initial `wallet_createSession` request 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 — `forceRequest` is the fourth positional argument +- The multichain `connect` signature is `connect(scopes, caipAccountIds, sessionProperties?, forceRequest?)` — all positional arguments, not an options object +- `wallet_requestPermissions` itself does not take a `forceRequest` parameter; the SDK handles that through `connect()` +- Without `forceRequest`, the SDK may reuse an existing compatible session +- `connect()` internally handles the underlying permission request flow, so you rarely need to call `wallet_requestPermissions` directly +- 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 by `createMultichainClient` (`@metamask/connect-multichain` 0.15.0+), `createEVMClient` (`@metamask/connect-evm` 1.4.0+), and `createSolanaClient` (`@metamask/connect-solana` 1.2.0+) +- Setting `analytics.enabled: false` on `createMultichainClient` also omits the `analytics.remote_session_id` field 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/analytics` 0.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 `createSolanaClient` is `"MetaMask"` (renamed from `"MetaMask Connect"` in `@metamask/connect-solana` 1.0.0). Match on exactly `"MetaMask"` — do not branch on the old `"MetaMask Connect"` literal. +- Since `@metamask/connect-solana` 1.0.0, `createSolanaClient` no 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. +- `WalletProvider` must receive `wallets={[]}` — 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 `createSolanaClient` early in app startup, but it does not need to resolve before the first `WalletProvider` render +- If your UI depends on MetaMask already being registered, gate that UI until `createSolanaClient` resolves +- Since `@metamask/connect-solana` 1.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 separate `wallet_sessionChanged` event to read accounts on cold start +- Since `@metamask/connect-solana` 1.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, React `useRef`, or `useMemo` — do not call `getWallet()` 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 `invokeMethod` and `connect` + +## 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()` / `invokeMethod` errors rather than treating devnet/testnet as guaranteed +- For Solana read calls, point a `@solana/web3.js` `Connection` at 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_getBalance` go to Infura), every Solana `invokeMethod` call 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.js` `Connection` directly 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's `disconnect()` 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-react` on 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`) with `invokeMethod` directly +- Do not attempt to import `@solana/wallet-adapter-react` or `@solana/wallet-adapter-react-ui` in 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) + +```typescript +// 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.getRandomValues` natively +- 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-multichain` self-polyfills `Buffer` via its React Native entry point — not needed for the SDK itself +- Still recommended to set `global.Buffer = Buffer` in `polyfills.ts` as 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 inspects `window` and `global.navigator.product` to 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, but `isSecure()` 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 `eventemitter3` for all internal eventing; DOM `Event`/`CustomEvent` are never constructed in React Native code paths +- **Required when using wagmi** — wagmi core dispatches DOM events internally +- Add only if your integration uses wagmi: + +```typescript +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` +- **`stream`** must map to `readable-stream` (not `stream-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): + +```javascript +// 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-stream` needs to be installed — do not install `react-native-crypto`, `@tradle/react-native-http`, `https-browserify`, or `os-browserify`; they are obsolete for this SDK + +## preferredOpenLink (Required) + +- `mobile.preferredOpenLink` must 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-storage` for session persistence +- With wagmi: use `createAsyncStoragePersister` from `@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 diff --git a/skills/metamask-connect/references/troubleshooting.md b/skills/metamask-connect/references/troubleshooting.md new file mode 100644 index 00000000..6706f48c --- /dev/null +++ b/skills/metamask-connect/references/troubleshooting.md @@ -0,0 +1,492 @@ +# Troubleshoot MetaMask Connect Issues + +## When to use + +Use this skill when: + +- A connection attempt hangs, fails, or produces an unexpected error +- React Native apps crash on import or at runtime with missing polyfill errors +- QR codes don't appear or deeplinks don't open MetaMask Mobile +- Solana wallet adapter doesn't detect MetaMask +- Sessions are lost after page reload or disconnect behaves unexpectedly +- You need a systematic checklist to verify a MetaMask Connect integration + +## Symptom -> Cause -> Fix Reference + +--- + +### 1. Connection hangs / nothing happens after `connect()` + +**Cause A:** Extension not detected but `preferExtension` is `true` (the default). The SDK falls through to MetaMask Wallet Protocol (MWP) but no QR code is rendered because headless mode is on and there is no `display_uri` listener. + +**Fix:** Register a `display_uri` event listener to render the QR code URI before calling `connect()` + +**Cause B:** A concurrent `connect()` call is already in progress over MWP. + +**Fix:** Guard against double-clicking. Wrap `connect()` in a loading-state check and match on the `"Existing connection is pending"` error message — on MWP this error has **no numeric code**. (`-32002` only appears on the extension transport.) + +--- + +### 2. User rejected request (code `4001`) + +**Cause:** The user clicked "Reject" in MetaMask. This is normal behavior. + +**Fix:** Handle gracefully — show a retry button. Do not treat this as an application error or log it to error-tracking services: + +```typescript +try { + await client.connect({ chainIds: ['0x1'] }); +} catch (err) { + if (err.code === 4001) { + // User rejected — show retry UI + return; + } + throw err; +} +``` + +--- + +### 3. Connection already pending (code `-32002`) + +**Cause:** A previous `connect()` call has not yet resolved (the user may still have the MetaMask approval dialog open on mobile). + +**Fix:** Show a message like "Check MetaMask Mobile to approve the connection." Do **not** call `connect()` again — the original promise will resolve once the user acts. + +--- + +### 4. Chain not configured in `supportedNetworks` + +**Cause:** An RPC request was made on a chain whose CAIP scope is missing from `api.supportedNetworks`. This error is thrown by the EIP-1193 provider's `request()` path for the _active_ chain's node-routed reads — not by `connect()` (which only checks `chainIds` is non-empty) and not by `wallet_switchEthereumChain` (forwarded to the wallet). + +**Fix:** Add every chain the dApp needs to `supportedNetworks` with a valid RPC URL: + +```typescript +const client = await createEVMClient({ + dapp: { name: 'My DApp' }, + api: { + supportedNetworks: { + ...getInfuraRpcUrls({ + infuraApiKey: 'YOUR_INFURA_KEY', + chainIds: ['0x1', '0x89', '0xaa36a7'], + }), + '0xa4b1': 'https://arb1.arbitrum.io/rpc', + }, + }, +}); +``` + +--- + +### 5. `Cannot find variable: Buffer` / `Buffer is not defined` (React Native) + +**Cause:** A dependency loaded before `@metamask/connect-multichain` uses `Buffer`. The connect package self-polyfills Buffer via its React Native entry point, but peer dependencies like `eciesjs` may execute first. + +**Fix:** Add this to `polyfills.ts` and import it early (after `react-native-get-random-values`, before other imports): + +```typescript +import { Buffer } from 'buffer'; +global.Buffer = Buffer; +``` + +--- + +### 6. `Cannot find variable: Event` / `CustomEvent is not defined` (React Native) + +**Cause:** wagmi dispatches DOM events internally, and React Native does not provide `Event`/`CustomEvent` globals. The `@metamask/connect-*` packages themselves never construct DOM events (they use `eventemitter3`) — this error only occurs when wagmi (or another DOM-dependent library) is in the stack. + +**Fix:** If you use wagmi in React Native, add standalone class polyfills in `polyfills.ts`. Do **not** `extends Event` — that references the very global that is missing: + +```typescript +class EventPolyfill { + type: string; + constructor(type: string) { + this.type = type; + } +} + +class CustomEventPolyfill extends EventPolyfill { + detail: any; + constructor(type: string, options?: { detail?: any }) { + super(type); + this.detail = options?.detail; + } +} + +global.Event = EventPolyfill as any; +global.CustomEvent = CustomEventPolyfill as any; +``` + +If you are not using wagmi and still see this error, the source is another dependency — not the MetaMask Connect SDK. + +--- + +### 7. Deeplinks not opening MetaMask app (React Native) + +**Cause:** The `mobile.preferredOpenLink` callback is not configured. + +**Fix:** Pass a function that calls `Linking.openURL`: + +```typescript +import { Linking } from 'react-native'; + +const client = await createEVMClient({ + dapp: { name: 'My DApp', url: 'https://mydapp.com' }, + mobile: { + preferredOpenLink: (deeplink: string) => Linking.openURL(deeplink), + }, +}); +``` + +--- + +### 8. App crashes on import of SDK (React Native) + +**Cause:** Metro bundler cannot resolve Node.js built-in modules (`stream`, `crypto`, `http`, `https`, `os`, `url`, `assert`, `events`, etc.) that SDK dependencies reference. + +**Fix:** Add `extraNodeModules` shims in `metro.config.js`: + +```javascript +const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config'); +const path = require('path'); + +// src/empty-module.js: `module.exports = {};` +// Only `stream` needs a real shim — the other Node built-ins are referenced +// by transitive deps but never called at runtime in React Native. +const emptyModule = path.resolve(__dirname, 'src', 'empty-module.js'); + +const config = { + 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, + }, + }, +}; + +module.exports = mergeConfig(getDefaultConfig(__dirname), config); +``` + +Install the corresponding shim packages via `npm install`. + +--- + +### 9. `crypto.getRandomValues is not a function` (React Native) + +**Cause:** `react-native-get-random-values` is either not installed or not imported as the very first import. + +**Fix:** Import it as the **first line** of your entry file — before any other import: + +```typescript +import 'react-native-get-random-values'; +// all other imports follow +``` + +--- + +### 10. MetaMask wallet not appearing in Solana wallet adapter + +**Cause A:** `createSolanaClient` was never called (or was called only inside a component that hasn't mounted). Note that registration happens ~1 second _after_ the factory resolves, and the wallet adapter discovers late registrations automatically — so a briefly empty wallet list right after startup is normal. + +**Fix:** Call client creation once in your bootstrap. Rendering does not need to block on it: + +```typescript +import { createSolanaClient } from '@metamask/connect-solana'; + +// Kick off client creation — no need to await before rendering; +// the wallet registers via the wallet-standard register event (~1s later) +void createSolanaClient({ + dapp: { name: 'My DApp', url: window.location.href }, +}); + +const root = createRoot(document.getElementById('root')!); +root.render(); +``` + +**Cause B:** The `wallets` prop on `WalletProvider` is not an empty array. MetaMask uses the wallet-standard auto-discovery protocol and must **not** be listed manually. + +**Fix:** Always pass `wallets={[]}`: + +```tsx + + + + + +``` + +--- + +### 11. Solana devnet / testnet not working + +**Cause:** The SDK models mainnet, devnet, and testnet Solana scopes, but a given cluster's availability depends on the connected MetaMask build/version — and public cluster RPC endpoints are frequently rate-limited or flaky. + +**Fix:** Confirm the connected wallet actually granted the devnet/testnet scope (inspect `session.sessionScopes`), and don't assume a non-mainnet cluster is present — handle the connection error. If the scope is granted but reads fail, the issue is likely an unreliable RPC endpoint; use a dedicated provider instead of the public default: + +```typescript +// Public endpoints can be rate-limited or unavailable — use a dedicated RPC: +const endpoint = 'https://api.devnet.solana.com'; // or your own Infura /Helius / QuickNode / Alchemy URL +``` + +--- + +### 12. Session lost after page reload + +**Cause:** The app is not re-deriving UI state after the automatic session restore. The EVM client syncs any persisted session **before** `createEVMClient` resolves, then re-emits `connect`/`accountsChanged` on the provider. (The EIP-1193 provider never emits `wallet_sessionChanged` — that event exists only on the multichain client.) + +**Fix:** Check the cached state right after client creation, and subscribe to the provider events: + +```typescript +const client = await createEVMClient({ + /* ... */ +}); + +// Synchronous check — a restored session is already reflected here +const account = client.getAccount(); +if (account) { + updateUI([account], client.getChainId()); +} + +const provider = client.getProvider(); +provider.on('connect', ({ accounts, chainId }) => updateUI(accounts, chainId)); +provider.on('accountsChanged', (accounts) => + updateUI(accounts, client.getChainId()), +); +``` + +If you use the multichain client directly, listen there instead: `client.on('wallet_sessionChanged', (session) => session?.sessionScopes ...)`. + +Do not call `connect()` again immediately on page load if a session already exists. + +--- + +### 13. `disconnect()` doesn't fully disconnect + +**Cause:** Disconnect behavior differs by client. On the **multichain** client (`createMultichainClient`), `disconnect(scopes)` with specific CAIP scopes only revokes those scopes; `disconnect()` with no arguments revokes all. On the **EVM** client (`createEVMClient`), `disconnect()` takes **no arguments** and revokes only `eip155:*` scopes. On the **Solana** client (`createSolanaClient`), `disconnect()` takes no arguments and revokes only the Solana scopes. + +**Fix:** To fully terminate a multichain session, call the multichain client's `disconnect()` with no arguments: + +```typescript +// Multichain client — partial revoke (only the specified scope) +await multichainClient.disconnect(['eip155:1']); + +// Multichain client — full disconnect (all scopes) +await multichainClient.disconnect(); + +// EVM client — revokes eip155 scopes only (no scope argument) +await evmClient.disconnect(); +``` + +--- + +### 14. QR code not appearing + +**Cause A:** Headless mode is enabled but no `display_uri` listener is registered. The SDK generates the URI but has nowhere to render it. + +**Fix:** Register a `displayUri` handler (or a provider `display_uri` listener) **before** calling `connect()`. The EVM client itself has no `.on()` method: + +```typescript +const client = await createEVMClient({ + dapp: { name: 'My DApp', url: window.location.href }, + api: { + supportedNetworks: getInfuraRpcUrls({ infuraApiKey: 'YOUR_INFURA_KEY' }), + }, + ui: { headless: true }, + eventHandlers: { + displayUri: (uri) => renderQrCode(uri), // your QR rendering logic + }, +}); + +// Equivalent: client.getProvider().on('display_uri', renderQrCode); + +await client.connect({ chainIds: ['0x1'] }); +``` + +**Cause B:** The extension is detected and the SDK uses the extension transport instead of MWP. No QR is generated because none is needed. + +**Fix:** Force the MWP/QR flow by disabling extension preference: + +```typescript +const client = await createEVMClient({ + dapp: { name: 'My DApp', url: window.location.href }, + api: { + supportedNetworks: getInfuraRpcUrls({ infuraApiKey: 'YOUR_INFURA_KEY' }), + }, + ui: { preferExtension: false }, +}); +``` + +--- + +### 15. Extension transport used but want mobile QR + +**Cause:** `preferExtension` defaults to `true`. When the MetaMask browser extension is installed, the SDK always prefers it. + +**Fix:** Set `ui.preferExtension = false`: + +```typescript +const client = await createEVMClient({ + dapp: { name: 'My DApp', url: window.location.href }, + api: { + supportedNetworks: getInfuraRpcUrls({ infuraApiKey: 'YOUR_INFURA_KEY' }), + }, + ui: { preferExtension: false }, +}); +``` + +--- + +### 16. QR code modal blocked by dapp `Content-Security-Policy` + +**Cause:** Older versions of the QR modal created a `blob:` URL for the embedded MetaMask icon. If the host page's CSP `connect-src` directive did not include `blob:`, the `XMLHttpRequest` used to build the blob was rejected and the QR image failed to render. + +**Fix:** Upgrade to `@metamask/connect-multichain ^0.12.1` and `@metamask/multichain-ui ^0.4.1` (shipped in connect-monorepo `v30.0.0`). The icon is now embedded as a `data:` URI and `saveAsBlob: false` is set in the QR image options, so no `connect-src blob:` entry is needed: + +```bash +npm install @metamask/connect-multichain@^0.12.1 @metamask/multichain-ui@^0.4.1 +# or update @metamask/connect-evm to ^0.11.2 / @metamask/connect-solana to ^0.8.1 +# which pin the fixed multichain version transitively +``` + +--- + +### 17. `eth_coinbase` returns an array / inconsistent account responses + +**Cause:** Before `@metamask/connect-evm` 1.3.1, the SDK's intercepted EIP-1193 account requests returned the same accounts array for both `eth_requestAccounts` and `eth_coinbase`. Per spec, `eth_coinbase` should return a single address (`Address`), not an array. + +**Fix:** Upgrade to `@metamask/connect-evm` ^1.3.1 (connect-monorepo `v35.0.0`). After upgrade, `eth_requestAccounts` resolves to `Address[]` and `eth_coinbase` resolves to the currently selected account (`Address`). Update any code that destructured `eth_coinbase` as an array: + +```typescript +const accounts = await provider.request({ method: 'eth_requestAccounts' }); +const coinbase = await provider.request({ method: 'eth_coinbase' }); + +console.log(accounts[0]); // selected account +console.log(coinbase); // same address as accounts[0] — a string, NOT an array +``` + +```bash +npm install @metamask/connect-evm@^1.3.1 +``` + +--- + +### 18. `wallet_switchEthereumChain` masks `Unrecognized chain ID` with `No chain configuration found.` + +**Cause:** Before `@metamask/connect-evm` 1.2.0, calling `client.switchChain({ chainId })` without a `chainConfiguration` fallback (or invoking `wallet_switchEthereumChain` directly) replaced the wallet's original `Unrecognized chain ID` error with the wrapper message `No chain configuration found.`, hiding the underlying `4902` code from the dapp. + +**Fix:** Upgrade to `@metamask/connect-evm` ^1.2.0 (connect-monorepo `v33.0.0`). The original wallet error (EIP-1193 code `4902`) is now forwarded to the dapp. Handle it explicitly — either retry with a `chainConfiguration` fallback or call `wallet_addEthereumChain`: + +```typescript +try { + await client.switchChain({ chainId: '0xa4b1' }); +} catch (err) { + if ((err as { code?: number }).code === 4902) { + await client.switchChain({ + chainId: '0xa4b1', + chainConfiguration: { + chainId: '0xa4b1', + chainName: 'Arbitrum One', + rpcUrls: ['https://arb1.arbitrum.io/rpc'], + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + }, + }); + return; + } + throw err; +} +``` + +Do not pattern-match on the legacy `"No chain configuration found"` string — that branch will never fire after the upgrade. + +--- + +### 19. Analytics `_rejected` count looks artificially high / `wallet_unauthorized` mis-classified + +**Cause:** Before `@metamask/connect-multichain` 0.14.0, the `isRejectionError` helper that drives the `mmconnect_wallet_action_rejected` analytics event treated EIP-1193 `4100 Unauthorized` (a CAIP-25 permission denial) as a user rejection, matched any error message containing the bare substring `"user"` (catching unrelated phrases like Account Abstraction's `"user operation reverted"`), and masked wallet-side codes behind the router's transport-boundary wrapper (`code: 53`). + +**Fix:** Upgrade to `@metamask/connect-multichain` ^0.14.0 (connect-monorepo `v34.0.0`). The classifier now: + +- Unwraps `RPCInvokeMethodErr` so wallet-side codes survive the router boundary +- No longer counts `4100 wallet_unauthorized` as a rejection — it's a permission denial, surfaced under `mmconnect_wallet_action_failed` instead +- Narrows the substring match to four explicit phrases: `"user rejected"`, `"user denied"`, `"user cancelled"`, `"user canceled"` + +Net effect: `_rejected` becomes more precise, and `_failed` picks up everything `4100` was previously hiding. Update analytics dashboards / alerts that compared `_rejected` counts across the 0.13.x → 0.14.0 boundary — expect `_rejected` to drop and `_failed` to rise without an underlying behavior change. + +The same release adds three optional companion fields on `mmconnect_wallet_action_failed` and `mmconnect_connection_failed`: + +- `failure_reason` — coarse classifier (transport timeout, transport disconnect, EIP-1193 wallet errors `4100` / `4200` / `4902`, JSON-RPC wallet errors `-32601` / `-32602` / `-32603` and `-32000…-32099`, or `unknown`) +- `error_code` — raw wallet-side JSON-RPC / EIP-1193 code (e.g. `4001`, `-32603`) +- `error_message_sample` — sanitised, 200-char-max preview of the original error message (wallet addresses, hex blobs, URLs, and large decimal numbers scrubbed) + +Use these for finer triage in analytics consumers: + +```bash +npm install @metamask/connect-multichain@^0.14.0 +# or update @metamask/connect-evm to ^1.3.0 / @metamask/connect-solana to ^1.1.0 +# which pin the fixed multichain version transitively +``` + +--- + +### 20. Module-not-found / peer-version warning for `@metamask/connect-multichain` after upgrading to `connect-evm` 2.0.0 or `connect-solana` 2.0.0 + +**Cause:** You are on the 2.0.0 releases of `@metamask/connect-evm`/`@metamask/connect-solana`, which (only in that version) made `@metamask/connect-multichain` a **peer dependency** that was not installed transitively. 2.1.0 reverted this — `@metamask/connect-multichain` is a regular dependency again. If a wrong or duplicate version is resolved, the SDK logs a runtime warning about a version mismatch or duplicate `@metamask/connect-multichain` resolutions. + +**Fix:** Add it explicitly to your own `dependencies`: + +```bash +npm install @metamask/connect-multichain@^1.0.0 +``` + +- Ensure a **single** `@metamask/connect-multichain` resolves in your tree — `npm ls @metamask/connect-multichain` (or `yarn why` / `pnpm why`) should show one `1.x` version. Deduplicate (e.g. `npm dedupe`) if two copies appear, since duplicate resolutions trigger the runtime warning and can break singleton/session sharing. +- `@metamask/connect-multichain` is now a stable 1.0 package following strict semver, so `^1.0.0` is safe for all current ecosystem packages. + +--- + +### 21. MMConnect provider shows up twice in wallet discovery, or the wrong provider is selected + +**Cause:** Since `@metamask/connect-evm` 2.0.0, the MMConnect-managed EIP-1193 provider is announced through EIP-6963 **by default** (when native MetaMask hasn't already announced). If your app also announces a provider manually, or a discovery library (RainbowKit / ConnectKit / Web3Modal / wagmi) re-announces, you can end up with a duplicate MetaMask-style entry. + +**Fix:** + +- Pass `skipAutoAnnounce: true` to `createEVMClient()` to suppress the automatic announcement when you want to control discovery yourself, then call `client.announceProvider()` exactly when you need to surface it. +- Do **not** manually re-emit `eip6963:announceProvider` for the MMConnect provider in addition to the SDK — let the SDK own it, or use `skipAutoAnnounce` + `announceProvider()`, not both. +- Note the SDK restricts EIP-6963 extension detection to native MetaMask RDNS values, so the MMConnect-managed provider will not be mistaken for — or select — the browser-extension transport. + +--- + +## Diagnostic Checklist + +Run through this checklist when any MetaMask Connect integration is misbehaving: + +- [ ] **`supportedNetworks` has valid RPC URLs** — every chain the dApp uses must have an entry with a reachable URL +- [ ] **Chain IDs are hex strings for EVM** — use `'0x1'` not `1` or `'1'` +- [ ] **Polyfills loaded (React Native)** — `react-native-get-random-values` is first entry-file import (required for RN < 0.72); `window` shim present (required for all); `Event`/`CustomEvent` shims present **only if using wagmi**; `Buffer` set as safety net for peer deps +- [ ] **`preferredOpenLink` set (React Native)** — required for deeplinks to open MetaMask Mobile +- [ ] **Import order correct** — polyfills before SDK imports; `react-native-get-random-values` is the very first import +- [ ] **Error codes handled in catch blocks** — at minimum handle `4001` (user rejected) and `-32002` (pending) +- [ ] **Client not recreated per render** — call `createEVMClient` / `createMultichainClient` / `createSolanaClient` once; the shared multichain core is the singleton (its options merge), but each `create*Client` call still returns a fresh wrapper +- [ ] **`display_uri` listener registered before `connect()`** — required in headless mode for QR codes +- [ ] **Solana `wallets` prop is `[]`** — MetaMask uses wallet-standard discovery, not manual registration +- [ ] **Solana network availability checked** — mainnet/devnet/testnet scopes are all modeled by the SDK; don't assume a non-mainnet cluster is available on the connected wallet — handle connection errors +- [ ] **Analytics consumers use `failure_reason` / `error_code` / `error_message_sample`** — for `mmconnect_wallet_action_failed` / `mmconnect_connection_failed` triage (added in `@metamask/connect-multichain` 0.14.0); expect `_rejected` counts to drop and `_failed` counts to rise after upgrading past 0.13.x + +## Important Notes + +- Always check the **error code** first — it tells you the category of failure before you need to inspect the message. +- Use typed error classes from `@metamask/connect-multichain` for granular `instanceof` checks: `RPCInvokeMethodErr` (wallet errors from `invokeMethod` — original wallet code on `rpcCode`, revert data on `rpcData`), `RPCHttpErr` / `RPCReadonlyResponseErr` / `RPCReadonlyRequestErr` (RPC-node-routed read calls). +- The underlying multichain core is a **singleton**: `createMultichainClient` merges options into the shared core, and `createEVMClient` / `createSolanaClient` build chain-specific wrappers on top of it. Each `create*Client` call returns a fresh wrapper, so call it once at startup and reuse — do not wrap it in a React component render cycle. +- **Extension detection is synchronous** but **MWP connection is asynchronous** — if the extension is not installed, expect the flow to involve QR scanning or deeplinks with noticeable latency. +- In React Native, **import order matters critically**. `react-native-get-random-values` must be the very first import in the entry file (not inside `polyfills.ts`). The connect-\* packages do not use DOM `Event`/`CustomEvent` — those polyfills are only needed when also using wagmi. `@metamask/connect-multichain` self-polyfills `Buffer` but set `global.Buffer` early as a safety net for peer deps. +- When debugging, enable `debug: true` in the client options to get verbose console output from the SDK internals. diff --git a/skills/metamask-connect/workflows/migrate-from-sdk.md b/skills/metamask-connect/workflows/migrate-from-sdk.md new file mode 100644 index 00000000..2aa3b040 --- /dev/null +++ b/skills/metamask-connect/workflows/migrate-from-sdk.md @@ -0,0 +1,379 @@ +# Migrate from @metamask/sdk to @metamask/connect + +## When to use + +Use this skill when: + +- Migrating an existing dApp from `@metamask/sdk` or `@metamask/sdk-react` to the new `@metamask/connect-*` packages +- Updating initialization code, provider access, or event handling for the new API +- Converting a wagmi integration to use the new `metaMask()` connector +- Adding multichain or Solana support during the migration + +## Workflow + +### Step 1: Replace packages + +Remove the old packages and install the new ones: + +```bash +# Remove old +npm uninstall @metamask/sdk @metamask/sdk-react + +# Install new — pick the packages you need +npm install @metamask/connect-evm +npm install @metamask/connect-multichain +npm install @metamask/connect-solana +``` + +--- + +### Step 1b: React Native polyfills (if applicable) + +No polyfill configuration is needed for web environments (Vite, Webpack, Next.js, etc.) — `@metamask/connect-*` packages no longer depend on Node.js built-ins in the browser. + +**React Native only:** Polyfills must be imported in a specific order. See the `react-native-polyfills` rule for required import order, window/Event/CustomEvent shims, and metro configuration. Note: `Buffer` is self-polyfilled by `@metamask/connect-multichain` but should still be set early as a safety net for peer deps. + +--- + +### Step 2: Update imports + +**Old:** + +```typescript +import { MetaMaskSDK } from '@metamask/sdk'; +import { MetaMaskProvider, useSDK } from '@metamask/sdk-react'; +``` + +**New (EVM):** + +```typescript +import { createEVMClient, getInfuraRpcUrls } from '@metamask/connect-evm'; +``` + +**New (Multichain):** + +```typescript +import { createMultichainClient } from '@metamask/connect-multichain'; +``` + +**New (Solana):** + +```typescript +import { createSolanaClient } from '@metamask/connect-solana'; +``` + +**New (wagmi connector):** + +```typescript +// Requires wagmi >= 3.6 / @wagmi/connectors >= 8 (the connect-evm-backed +// connector), with @metamask/connect-evm installed at wagmi's declared peer +// range (currently ^1.3.0). On older wagmi, copy the reference connector +// from connect-monorepo/integrations/wagmi/metamask-connector.ts. +import { metaMask } from 'wagmi/connectors'; +``` + +--- + +### Step 3: Update initialization + +**Old:** + +```typescript +const sdk = new MetaMaskSDK({ + dappMetadata: { + name: 'My DApp', + url: window.location.href, + }, + infuraAPIKey: 'YOUR_INFURA_KEY', + readonlyRPCMap: { + '0x89': 'https://polygon-rpc.com', + }, + headless: true, + extensionOnly: false, + openDeeplink: (link) => window.open(link, '_blank'), +}); +await sdk.init(); +``` + +**New:** + +```typescript +const client = await createEVMClient({ + dapp: { + name: 'My DApp', + url: window.location.href, + }, + api: { + supportedNetworks: { + ...getInfuraRpcUrls({ + infuraApiKey: 'YOUR_INFURA_KEY', + chainIds: ['0x1', '0x89'], + }), + '0xa4b1': 'https://arb1.arbitrum.io/rpc', + }, + }, + ui: { + headless: true, + preferExtension: false, + }, + // `mobile` block only needed for React Native + // mobile: { + // preferredOpenLink: (link: string) => Linking.openURL(link), + // }, +}); +``` + +**Key option mappings:** + +| Old (`MetaMaskSDK`) | New (`createEVMClient`) | Notes | +| ------------------------ | --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `dappMetadata` | `dapp` | Same shape: `{ name, url, iconUrl }` | +| `dappMetadata.name` | `dapp.name` | Required | +| `dappMetadata.url` | `dapp.url` | Optional | +| `infuraAPIKey` | `api.supportedNetworks` via `getInfuraRpcUrls({ infuraApiKey: key })` | Helper generates URLs for all Infura-supported chains; optional `chainIds` to limit to specific chains | +| `readonlyRPCMap` | `api.supportedNetworks` | Merge into the same object | +| `headless` | `ui.headless` | Same behavior | +| `extensionOnly` | `ui.preferExtension` | `true` prefers extension (default); not the same as "only" | +| `openDeeplink` | `mobile.preferredOpenLink` | Same signature: `(deeplink: string) => void` | +| `useDeeplink` | `mobile.useDeeplink` | Same behavior | +| `timer` | Removed | No longer configurable | +| `enableAnalytics` | `analytics: { enabled: boolean }` | Pass `analytics: { enabled: false }` at client creation. (A runtime `analytics.disable()` exists on the `@metamask/analytics` singleton — `import { analytics } from '@metamask/analytics'` — it is **not** a method on the connect client.) | +| `communicationServerUrl` | Removed | Managed internally | +| `storage` | Removed | Managed internally | + +--- + +### Step 4: Update connection flow + +**Old:** + +```typescript +const accounts = await sdk.connect(); +const chainId = await sdk.getProvider().request({ method: 'eth_chainId' }); +``` + +**New:** + +```typescript +const { accounts, chainId } = await client.connect({ + chainIds: ['0x1'], +}); +``` + +Key differences: + +- `connect()` now returns an **object** with both `accounts` and `chainId` — no separate call needed +- `chainIds` parameter specifies which chains to request (hex strings) +- Use `connectAndSign` for connect + personal_sign in one step: + +```typescript +const { accounts, chainId, signature } = await client.connectAndSign({ + chainIds: ['0x1'], + message: 'Sign in to My DApp', +}); +``` + +- Use `connectWith` for connect + arbitrary RPC method: + +```typescript +const { accounts, chainId, result } = await client.connectWith({ + chainIds: ['0x1'], + method: 'eth_sendTransaction', + params: [{ from: '0x...', to: '0x...', value: '0x0' }], +}); +``` + +--- + +### Step 5: Update provider access + +**Old:** + +```typescript +const provider = sdk.getProvider(); // SDKProvider +await provider.request({ method: 'eth_chainId' }); +``` + +**New:** + +```typescript +const provider = client.getProvider(); // EIP1193Provider +await provider.request({ method: 'eth_chainId' }); +``` + +Key differences: + +- The provider is now a standard **EIP-1193 provider**, not the custom `SDKProvider` +- The provider is available **immediately** after `createEVMClient` resolves — even before `connect()` +- Before connection, RPC calls that require an account will fail; read-only calls (like `eth_blockNumber`) work against `supportedNetworks` RPCs +- No more `sdk.getProvider()` returning `undefined` — the provider always exists + +--- + +### Step 6: Update event handling + +**Old:** + +```typescript +const provider = sdk.getProvider(); +provider.on('chainChanged', (chainId) => { + /* ... */ +}); +provider.on('accountsChanged', (accounts) => { + /* ... */ +}); +provider.on('disconnect', () => { + /* ... */ +}); +``` + +**New (same EIP-1193 events still work):** + +```typescript +const provider = client.getProvider(); +provider.on('chainChanged', (chainId) => { + /* ... */ +}); +provider.on('accountsChanged', (accounts) => { + /* ... */ +}); +provider.on('disconnect', () => { + /* ... */ +}); +``` + +**New (additional SDK-level events via constructor):** + +```typescript +const client = await createEVMClient({ + dapp: { name: 'My DApp' }, + eventHandlers: { + displayUri: (uri) => { + /* render QR code */ + }, + }, +}); +``` + +Or subscribe on the EIP-1193 provider after creation: + +```typescript +const provider = client.getProvider(); +provider.on('display_uri', (uri) => { + /* ... */ +}); +``` + +For `wallet_sessionChanged`, use the multichain client directly: + +```typescript +const client = await createMultichainClient({ + /* ... */ +}); +client.on('wallet_sessionChanged', (session) => { + /* ... */ +}); +``` + +--- + +### Step 7: New capabilities to adopt + +These features are **new** in the MetaMask Connect packages and have no old-SDK equivalent: + +| Capability | Description | +| --------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| **Multichain client** | `createMultichainClient` supports CAIP-25 scopes across EVM and non-EVM chains | +| **`invokeMethod`** | Call RPC methods on specific CAIP scopes: `client.invokeMethod({ scope: 'eip155:1', request: { method, params } })` | +| **Solana support** | `createSolanaClient` from `@metamask/connect-solana` with wallet-standard adapter | +| **`connectAndSign`** | Connect and sign a message in a single user approval | +| **`connectWith`** | Connect and execute any RPC method in a single user approval | +| **Partial disconnect** | `disconnect(scopes)` is available on the multichain client to revoke specific CAIP scopes while keeping others active | +| **Singleton client** | Subsequent `createMultichainClient` calls merge into the existing instance | +| **`wallet_sessionChanged`** | Multichain client event fired when session state changes or is restored | + +--- + +### Step 8: Wagmi migration + +**Old:** + +```typescript +// Old @metamask/sdk constructor takes flat options (no `options` wrapper): +import { MetaMaskSDK } from '@metamask/sdk'; + +const sdk = new MetaMaskSDK({ + dappMetadata: { name: 'My DApp', url: window.location.href }, +}); +// (or the legacy wagmi `metaMask()` connector that wrapped @metamask/sdk) +``` + +**New:** + +```typescript +import { createConfig, http } from 'wagmi'; +import { mainnet, sepolia } from 'wagmi/chains'; +import { metaMask } from 'wagmi/connectors'; + +export const wagmiConfig = createConfig({ + chains: [mainnet, sepolia], + connectors: [ + metaMask({ + dapp: { + name: 'My DApp', + url: typeof window !== 'undefined' ? window.location.href : undefined, + }, + }), + ], + transports: { + [mainnet.id]: http(), + [sepolia.id]: http(), + }, +}); +``` + +Key differences: + +- The connect-evm-backed `metaMask()` connector ships in `wagmi/connectors` from wagmi 3.6 / `@wagmi/connectors` 8 — there is no `@metamask/connect-evm/wagmi` subpath; install `@metamask/connect-evm` at wagmi's declared peer range +- Use `dapp` not `dappMetadata` +- Connector ID is `'metaMaskSDK'` — find it with `connectors.find(c => c.id === 'metaMaskSDK')` +- Most wagmi hooks work unchanged, but note the wagmi v3 renames: `useConnect().connectors` → `useConnectors()`, `connectAsync` → `mutateAsync`, `useAccount` → `useConnection` (see [`migrate-wagmi-connector.md`](migrate-wagmi-connector.md)) + +--- + +## Quick Reference: Full Option Mapping + +| Old (`@metamask/sdk`) | New (`@metamask/connect-*`) | Status | +| -------------------------- | -------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `new MetaMaskSDK(opts)` | `await createEVMClient(opts)` | Renamed, async | +| `sdk.init()` | Not needed | Init happens in `createEVMClient` | +| `sdk.connect()` | `client.connect({ chainIds })` | Returns `{ accounts, chainId }` | +| `sdk.getProvider()` | `client.getProvider()` | Returns EIP-1193 provider | +| `sdk.disconnect()` | `client.disconnect()` | Same for EVM; partial disconnect is multichain-only | +| `sdk.terminate()` | `client.disconnect()` | `terminate` is removed — the EVM client's `disconnect()` revokes the EVM (`eip155:*`) scopes; for full multi-ecosystem teardown call the multichain client's `disconnect()` with no arguments | +| `dappMetadata` | `dapp` | Renamed | +| `infuraAPIKey` | `getInfuraRpcUrls({ infuraApiKey: key })` in `api.supportedNetworks` | Helper function; optional `chainIds` filters to specific chains | +| `readonlyRPCMap` | `api.supportedNetworks` | Merged with Infura URLs | +| `headless` | `ui.headless` | Moved to `ui` namespace | +| `extensionOnly` | `ui.preferExtension` | Renamed, slightly different semantics | +| `openDeeplink` | `mobile.preferredOpenLink` | Moved to `mobile` namespace | +| `useDeeplink` | `mobile.useDeeplink` | Moved to `mobile` namespace | +| `MetaMaskProvider` (React) | No direct equivalent | Use wagmi `WagmiProvider` or call `createEVMClient` directly | +| `useSDK()` hook | No direct equivalent | Use wagmi hooks or manage client state manually | +| `SDKProvider` | `EIP1193Provider` | Standard provider interface | +| `timer` | Removed | — | +| `enableAnalytics` | `analytics: { enabled: boolean }` | — | +| `communicationServerUrl` | Removed | — | +| `storage` | Removed | — | + +## Important Notes + +- **`createEVMClient` is async** — unlike `new MetaMaskSDK()`, it returns a promise. Ensure you `await` it or handle the promise before accessing the client. +- **The multichain core is the singleton** — `createMultichainClient` merges into a shared instance, while EVM/Solana create wrappers on top of that shared core. Do not recreate clients on every render. +- **`connect()` returns an object now** — destructure `{ accounts, chainId }` instead of treating the return value as an accounts array. +- **Chain IDs must be hex strings** — use `'0x1'` not `1` or `'1'` in `chainIds` and `supportedNetworks` keys. +- **No more `sdk.init()`** — initialization is part of `createEVMClient`. There is no separate init step. +- **Provider exists before connection** — `client.getProvider()` never returns `undefined`. But node-routed reads (`eth_blockNumber`, `eth_getBalance`, …) require a **selected chain** and throw `No chain ID selected` until one is set (after `connect()` or a restored session); only the intercepted `eth_chainId` / `eth_accounts` (cached) are safe before connecting. +- **`@metamask/sdk-react` has no 1:1 replacement** — if you were using `MetaMaskProvider` and `useSDK()`, migrate to either wagmi hooks or manage the client instance in your own React context. +- **`sdk.terminate()` is replaced by `disconnect()`** — the EVM client's `disconnect()` revokes EVM (`eip155:*`) scopes only; if the session also has Solana scopes, terminate everything via the multichain client's `disconnect()` with no arguments. There is no separate `terminate` method. +- **Test the migration on both extension and mobile** — the transport layer has changed, and behavior differences may surface in one environment but not the other. diff --git a/skills/metamask-connect/workflows/migrate-wagmi-connector.md b/skills/metamask-connect/workflows/migrate-wagmi-connector.md new file mode 100644 index 00000000..91616277 --- /dev/null +++ b/skills/metamask-connect/workflows/migrate-wagmi-connector.md @@ -0,0 +1,277 @@ +# Migrate Wagmi MetaMask Connector to @metamask/connect-evm + +## When to use + +- Upgrading a wagmi project from `@wagmi/connectors` v6.x/v7.x (which bundled `@metamask/sdk`) to v8.x+ / wagmi >= 3.6 (which uses `@metamask/connect-evm`) +- You see errors like `Cannot find module '@metamask/sdk'` after updating wagmi +- You want to adopt the new MetaMask Connect SDK in an existing wagmi app +- Consumer is migrating to the latest wagmi version that includes the MetaMask connector refactor (PR [#4960](https://github.com/wevm/wagmi/pull/4960)) + +## Breaking Change Summary + +The MetaMask connector in wagmi has been **completely rewritten**. The underlying SDK changed from `@metamask/sdk` to `@metamask/connect-evm`. The connector now dynamically imports `@metamask/connect-evm` instead of bundling `@metamask/sdk`. + +**Key impacts:** + +- New optional peer dependency: `@metamask/connect-evm` must be installed explicitly, at a version inside wagmi's declared peer range (check `npm info @wagmi/connectors peerDependencies` — currently `^1.3.0`) +- Old dependency `@metamask/sdk` should be removed +- Configuration parameter names changed (`dappMetadata` → `dapp`, `useDeeplink` → `mobile.useDeeplink`) +- Several deprecated SDK-specific options are removed entirely +- Internal provider type changed from `SDKProvider` to `EIP1193Provider` + +## Workflow + +### Step 1: Update Dependencies + +Remove the old SDK and install the new one: + +```bash +# npm +npm uninstall @metamask/sdk +npm install @metamask/connect-evm + +# pnpm +pnpm remove @metamask/sdk +pnpm add @metamask/connect-evm + +# yarn +yarn remove @metamask/sdk +yarn add @metamask/connect-evm +``` + +Then update wagmi packages to the latest: + +```bash +npm install wagmi@latest @wagmi/core@latest @wagmi/connectors@latest +``` + +### Step 2: Update MetaMask Connector Configuration + +#### Before (old `@metamask/sdk` options): + +```typescript +import { metaMask } from 'wagmi/connectors'; + +metaMask({ + dappMetadata: { + name: 'My Dapp', + url: 'https://mydapp.com', + }, + useDeeplink: true, + logging: { sdk: false }, + // These SDK-specific options are REMOVED: + forceDeleteProvider: false, + forceInjectProvider: false, + injectProvider: false, +}); +``` + +#### After (new `@metamask/connect-evm` options): + +```typescript +import { metaMask } from 'wagmi/connectors'; + +metaMask({ + dapp: { + name: 'My Dapp', + url: 'https://mydapp.com', + iconUrl: 'https://mydapp.com/icon.png', // new optional field + }, + debug: false, + // Mobile options are now nested: + mobile: { + useDeeplink: true, + preferredOpenLink: undefined, // required for React Native + }, +}); +``` + +### Step 3: Configuration Parameter Migration Reference + +| Old Parameter (`@metamask/sdk`) | New Parameter (`@metamask/connect-evm`) | Notes | +| ------------------------------- | --------------------------------------- | --------------------------------------------- | +| `dappMetadata: { name, url }` | `dapp: { name, url, iconUrl }` | `dappMetadata` still works but is deprecated | +| `logging: { sdk: true }` | `debug: true` | `logging` still works but is deprecated | +| `useDeeplink: boolean` | `mobile: { useDeeplink: boolean }` | Moved into `mobile` namespace | +| `preferredOpenLink` | `mobile: { preferredOpenLink }` | Moved into `mobile` namespace | +| `forceDeleteProvider` | _(removed)_ | No replacement — not needed with new SDK | +| `forceInjectProvider` | _(removed)_ | No replacement — not needed with new SDK | +| `injectProvider` | _(removed)_ | No replacement — not needed with new SDK | +| `readonlyRPCMap` | _(auto-configured)_ | Built automatically from wagmi's chain config | +| `_source` | _(auto-set to 'wagmi')_ | Set internally by the connector | + +### Step 4: Update connectAndSign / connectWith Usage (if applicable) + +The `connectAndSign` parameter name changed from `msg` to `message` internally. However, at the wagmi connector level the API is the same — you still pass `connectAndSign: 'message string'` in the `metaMask()` parameters. + +```typescript +// Still works the same at the wagmi config level: +metaMask({ + dapp: { name: 'My Dapp' }, + connectAndSign: 'Please sign this message to verify your identity', +}); +``` + +The `connectWith` API is also unchanged at the wagmi level: + +```typescript +metaMask({ + dapp: { name: 'My Dapp' }, + connectWith: { + method: 'eth_signTypedData_v4', + params: [address, typedData], + }, +}); +``` + +### Step 5: Handle Provider Type Changes + +If your code directly accesses the provider from the connector, the type has changed: + +```typescript +// Before: provider was SDKProvider from @metamask/sdk +// After: provider is EIP1193Provider from @metamask/connect-evm + +// The EIP1193Provider interface is the same standard interface, +// so provider.request() calls remain unchanged. + +// New: You can access the underlying MetamaskConnectEVM instance: +const connector = config.connectors.find((c) => c.id === 'metaMaskSDK'); +if (connector) { + const instance = await connector.getInstance(); + // instance.accounts, instance.getChainId(), instance.switchChain(), etc. +} +``` + +### Step 6: Remove Deprecated Patterns + +The new connector handles event listeners internally. If you had code that manually managed MetaMask SDK event listeners, you can remove it: + +```typescript +// REMOVE any manual SDK event management like: +// sdk.on('accountsChanged', ...) +// sdk.on('chainChanged', ...) +// provider.removeListener(...) + +// Event handlers are now passed to createEVMClient internally. +// Wagmi hooks (useAccount, useChainId, etc.) handle state automatically. +``` + +### Step 7: Additional Wagmi API Renames (same major version) + +This wagmi release also includes several API renames. Deprecated aliases are provided but you should migrate: + +| Old API | New API | Package | +| -------------------- | ------------------------- | ------------------------ | +| `useAccount()` | `useConnection()` | `wagmi` | +| `useAccountEffect()` | `useConnectionEffect()` | `wagmi` | +| `useSwitchAccount()` | `useSwitchConnection()` | `wagmi` | +| `getAccount()` | `getConnection()` | `@wagmi/core` | +| `switchAccount()` | `switchConnection()` | `@wagmi/core` | +| `watchAccount()` | `watchConnection()` | `@wagmi/core` | +| `WagmiConfig` | `WagmiProvider` | `wagmi` (alias removed) | +| `useToken()` | `useReadContracts()` | `wagmi` (hook removed) | +| `useFeeData()` | `useEstimateFeesPerGas()` | `wagmi` (alias removed) | +| `normalizeChainId()` | _(removed)_ | `wagmi` (export removed) | + +### Step 8: Verify the Migration + +After making changes, verify: + +1. **Build succeeds** — `npm run build` or `tsc --noEmit` should pass +2. **No `@metamask/sdk` imports remain** — search your codebase: + ```bash + grep -r "@metamask/sdk" --include="*.ts" --include="*.tsx" --include="*.js" + ``` +3. **Wallet connection works** — test connecting via MetaMask browser extension +4. **Mobile deep-link works** (if applicable) — test QR code / deep-link flow +5. **Chain switching works** — test switching between configured chains +6. **Signing works** — test message signing and transaction signing + +## Complete Before/After Example + +### Before (`@wagmi/connectors` <= 7.x + @metamask/sdk): + +```typescript +import { createConfig, http } from 'wagmi'; +import { mainnet, sepolia, optimism } from 'wagmi/chains'; +import { metaMask } from 'wagmi/connectors'; + +export const config = createConfig({ + chains: [mainnet, sepolia, optimism], + connectors: [ + metaMask({ + dappMetadata: { + name: 'My Dapp', + url: window.location.origin, + }, + useDeeplink: true, + }), + ], + transports: { + [mainnet.id]: http(), + [sepolia.id]: http(), + [optimism.id]: http(), + }, +}); +``` + +### After (wagmi >= 3.6 / `@wagmi/connectors` >= 8 + @metamask/connect-evm): + +```typescript +import { createConfig, http } from 'wagmi'; +import { mainnet, sepolia, optimism } from 'wagmi/chains'; +import { metaMask } from 'wagmi/connectors'; + +export const config = createConfig({ + chains: [mainnet, sepolia, optimism], + connectors: [ + metaMask({ + dapp: { + name: 'My Dapp', + url: window.location.origin, + }, + mobile: { + useDeeplink: true, + }, + }), + ], + transports: { + [mainnet.id]: http(), + [sepolia.id]: http(), + [optimism.id]: http(), + }, +}); +``` + +## React Native Specific Migration + +If you are using wagmi with React Native, the `preferredOpenLink` callback has moved: + +```typescript +// Before: +metaMask({ + dappMetadata: { name: 'My RN App' }, + preferredOpenLink: (link, target) => Linking.openURL(link), + useDeeplink: true, +}); + +// After: +metaMask({ + dapp: { name: 'My RN App' }, + mobile: { + preferredOpenLink: (link, target) => Linking.openURL(link), + useDeeplink: true, + }, +}); +``` + +## Important Notes + +- `@metamask/connect-evm` is an **optional peer dependency** of `@wagmi/connectors` — you only need it if you use the `metaMask()` connector +- The connector ID remains `'metaMaskSDK'` and the name remains `'MetaMask'` — no changes to connector identity +- The connector's `rdns` is `['io.metamask', 'io.metamask.mobile']` — unchanged +- The `supportedNetworks` map is now auto-built from wagmi's configured chains and their default RPC URLs — you no longer need to pass `readonlyRPCMap` +- The `dappMetadata` parameter still works (it's mapped to `dapp` internally) but is deprecated — migrate to `dapp` for forward compatibility +- The `logging` parameter still works (mapped to `debug: true`) but is deprecated +- If no `dapp` config is provided, the connector defaults to `{ name: window.location.hostname, url: window.location.href }` in browsers, or `{ name: 'wagmi' }` in Node.js/SSR diff --git a/skills/metamask-connect/workflows/multichain-evm-operations.md b/skills/metamask-connect/workflows/multichain-evm-operations.md new file mode 100644 index 00000000..84788194 --- /dev/null +++ b/skills/metamask-connect/workflows/multichain-evm-operations.md @@ -0,0 +1,218 @@ +# Sign EVM Transactions via Multichain Client + +## When to use + +Use this skill when: + +- Sending EVM transactions through `invokeMethod` on a multichain client +- Signing messages with `personal_sign` or `eth_signTypedData_v4` +- Understanding which methods route to the RPC node vs the wallet +- Selecting the correct CAIP-2 EVM scope for a target chain + +## Workflow + +### Step 1: Ensure the client is connected with EVM scopes + +```typescript +import { + createMultichainClient, + getInfuraRpcUrls, +} from '@metamask/connect-multichain'; + +const client = await createMultichainClient({ + dapp: { name: 'My DApp', url: window.location.href }, + api: { + supportedNetworks: { + ...getInfuraRpcUrls({ + infuraApiKey: 'YOUR_INFURA_KEY', + caipChainIds: ['eip155:1', 'eip155:137'], + }), + }, + }, +}); + +await client.connect( + ['eip155:1', 'eip155:137'], // Ethereum mainnet + Polygon + [], +); +``` + +### Step 2: Understand RPC routing + +The multichain client routes EVM methods based on type: + +| Route | Methods | Transport | +| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | +| **RPC node** | `eth_call`, `eth_getBalance`, `eth_blockNumber`, `eth_getTransactionReceipt`, `eth_estimateGas`, `eth_getCode`, `eth_getLogs`, `eth_getTransactionCount` | Infura / custom RPC URL from `supportedNetworks` | +| **Wallet** | `eth_sendTransaction`, `personal_sign`, `eth_signTypedData_v4`, `wallet_switchEthereumChain`, `wallet_addEthereumChain` | MetaMask (extension or MWP) | + +The scope in `invokeMethod` determines which chain the request targets. Use `'eip155:1'` for Ethereum mainnet, `'eip155:137'` for Polygon, etc. + +### Step 3: Send a transaction (eth_sendTransaction) + +```typescript +const txHash = await client.invokeMethod({ + scope: 'eip155:1', + request: { + method: 'eth_sendTransaction', + params: [ + { + from: '0xYourAddress', + to: '0xRecipientAddress', + value: '0x2386F26FC10000', // 0.01 ETH in hex wei + gas: '0x5208', // 21000 gas + // gasPrice or maxFeePerGas/maxPriorityFeePerGas optional + }, + ], + }, +}); + +console.log('Transaction hash:', txHash); +``` + +**Estimating gas before sending:** + +```typescript +const gasEstimate = await client.invokeMethod({ + scope: 'eip155:1', + request: { + method: 'eth_estimateGas', + params: [ + { + from: '0xYourAddress', + to: '0xRecipientAddress', + value: '0x2386F26FC10000', + }, + ], + }, +}); +``` + +### Step 4: Sign a message (personal_sign) + +The message must be hex-encoded. The signer address is the second parameter. + +```typescript +const message = '0x' + Buffer.from('Hello MetaMask!').toString('hex'); + +const signature = await client.invokeMethod({ + scope: 'eip155:1', + request: { + method: 'personal_sign', + params: [message, '0xYourAddress'], + }, +}); + +console.log('Signature:', signature); +``` + +### Step 5: Sign typed data (eth_signTypedData_v4) + +Pass the signer address as the first parameter and the JSON-stringified typed data as the second. + +```typescript +const typedData = { + types: { + EIP712Domain: [ + { name: 'name', type: 'string' }, + { name: 'version', type: 'string' }, + { name: 'chainId', type: 'uint256' }, + { name: 'verifyingContract', type: 'address' }, + ], + Mail: [ + { name: 'from', type: 'string' }, + { name: 'to', type: 'string' }, + { name: 'contents', type: 'string' }, + ], + }, + primaryType: 'Mail', + domain: { + name: 'My DApp', + version: '1', + chainId: 1, + verifyingContract: '0xContractAddress', + }, + message: { + from: 'Alice', + to: 'Bob', + contents: 'Hello!', + }, +}; + +const signature = await client.invokeMethod({ + scope: 'eip155:1', + request: { + method: 'eth_signTypedData_v4', + params: ['0xYourAddress', JSON.stringify(typedData)], + }, +}); + +console.log('Typed data signature:', signature); +``` + +### Step 6: Cross-chain scope selection + +Each `invokeMethod` call targets a specific chain via its scope. You do not need to "switch chains" — just use the appropriate scope. + +```typescript +// Send on Polygon +await client.invokeMethod({ + scope: 'eip155:137', + request: { + method: 'eth_sendTransaction', + params: [{ from: '0x...', to: '0x...', value: '0xDE0B6B3A7640000' }], + }, +}); + +// Read balance on Ethereum +const ethBalance = await client.invokeMethod({ + scope: 'eip155:1', + request: { + method: 'eth_getBalance', + params: ['0xYourAddress', 'latest'], + }, +}); + +// Read balance on Polygon +const polyBalance = await client.invokeMethod({ + scope: 'eip155:137', + request: { + method: 'eth_getBalance', + params: ['0xYourAddress', 'latest'], + }, +}); +``` + +### Step 7: Error handling for signing + +```typescript +try { + const sig = await client.invokeMethod({ + scope: 'eip155:1', + request: { + method: 'personal_sign', + params: [hexMessage, signerAddress], + }, + }); +} catch (err) { + // Multichain invokeMethod errors are wrapped in RPCInvokeMethodErr (code 53); + // the wallet's original code is on err.rpcCode + if (err instanceof RPCInvokeMethodErr && err.rpcCode === 4001) { + // User rejected the signing request + return; + } + throw err; +} +``` + +(Import the class with `import { RPCInvokeMethodErr } from '@metamask/connect-multichain';`. Revert reasons / custom error bytes from the wallet are available on `err.rpcData`.) + +## Important Notes + +- **Scope = chain target:** The `scope` field in `invokeMethod` determines which chain the method executes on. Use `'eip155:'` format (e.g., `'eip155:1'`, `'eip155:137'`, `'eip155:42161'`). +- **No chain switching needed:** Unlike single-chain EVM clients, the multichain client does not require `wallet_switchEthereumChain`. Each call specifies its own scope. +- **Read vs sign routing:** Read-only methods go to the RPC node (fast, no user prompt). Signing methods go to the wallet (requires user approval in MetaMask). +- **Hex encoding:** `personal_sign` expects the message as a hex string (`0x...`). `eth_sendTransaction` expects `value`, `gas`, and other numeric fields as hex strings. +- **`eth_signTypedData_v4`:** The typed data parameter must be a JSON **string**, not an object. +- **Gas estimation:** Always estimate gas with `eth_estimateGas` before sending if you don't have a reliable gas value. This routes to the RPC node and does not prompt the user. +- **Connected scopes:** `invokeMethod` will fail if the target scope was not included in the `connect()` call. Ensure you connect with all chains you intend to use. diff --git a/skills/metamask-connect/workflows/multichain-solana-operations.md b/skills/metamask-connect/workflows/multichain-solana-operations.md new file mode 100644 index 00000000..1dc021c7 --- /dev/null +++ b/skills/metamask-connect/workflows/multichain-solana-operations.md @@ -0,0 +1,244 @@ +# Sign Solana Transactions via Multichain Client + +## When to use + +Use this skill when: + +- Signing or sending Solana transactions through `invokeMethod` on a multichain client +- Building Solana transactions with `@solana/web3.js` and encoding them for the multichain API +- Signing Solana messages through the multichain client +- Selecting the correct Solana CAIP-2 scope (mainnet, devnet) +- Disconnecting only Solana scopes while keeping EVM sessions active + +## Workflow + +### Step 1: Connect with Solana scopes + +```typescript +import { + createMultichainClient, + getInfuraRpcUrls, +} from '@metamask/connect-multichain'; + +const client = await createMultichainClient({ + dapp: { name: 'My DApp', url: window.location.href }, + api: { + supportedNetworks: { + ...getInfuraRpcUrls({ + infuraApiKey: 'YOUR_INFURA_KEY', + caipChainIds: ['eip155:1', 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'], + }), + }, + }, +}); + +await client.connect( + [ + 'eip155:1', + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', // mainnet + ], + [], +); // resolves with no value — read session via client.provider.getSession() +``` + +**Solana CAIP-2 scope identifiers:** + +| Network | CAIP-2 Scope | +| ------- | ----------------------------------------- | +| Mainnet | `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp` | +| Devnet | `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1` | +| Testnet | `solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z` | + +All three Solana scopes are modeled by the SDK; non-mainnet availability depends on the connected MetaMask build/version, so handle connection errors rather than assuming a cluster is present. + +### Step 2: Understand Solana RPC routing + +**All Solana methods route through the wallet.** Unlike EVM where read calls go to an RPC node, every Solana `invokeMethod` call is handled by MetaMask. There is no RPC node fallback for Solana. + +### Step 3: Sign a message (signMessage) + +Method names have **no `solana_` prefix**. The message must be **base64 encoded**, and the signing account is passed as `account: { address }`. + +```typescript +const message = btoa('Hello from Solana via MetaMask!'); + +const result = await client.invokeMethod({ + scope: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + request: { + method: 'signMessage', + params: { + account: { address: 'YourSolanaAddressBase58' }, + message, + }, + }, +}); + +// result: { signature: , signedMessage: , signatureType: 'ed25519' } +console.log('Signature:', result.signature); +``` + +### Step 4: Build a Solana transaction with @solana/web3.js + +Build the transaction using `@solana/web3.js`, serialize it, then base64-encode for `invokeMethod`. + +```typescript +import { + Connection, + PublicKey, + SystemProgram, + Transaction, + clusterApiUrl, +} from '@solana/web3.js'; + +const connection = new Connection(clusterApiUrl('mainnet-beta')); +const fromPubkey = new PublicKey('YourSolanaPublicKey'); +const toPubkey = new PublicKey('RecipientSolanaPublicKey'); + +const transaction = new Transaction().add( + SystemProgram.transfer({ + fromPubkey, + toPubkey, + lamports: 1_000_000, // 0.001 SOL + }), +); + +transaction.recentBlockhash = (await connection.getLatestBlockhash()).blockhash; +transaction.feePayer = fromPubkey; + +const serialized = transaction.serialize({ + requireAllSignatures: false, + verifySignatures: false, +}); +const base64Transaction = Buffer.from(serialized).toString('base64'); +``` + +### Step 5: Sign a transaction (signTransaction) + +Returns the signed transaction without broadcasting it. + +```typescript +const SOLANA_MAINNET = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'; + +const signResult = await client.invokeMethod({ + scope: SOLANA_MAINNET, + request: { + method: 'signTransaction', + params: { + account: { address: 'YourSolanaAddressBase58' }, + transaction: base64Transaction, + scope: SOLANA_MAINNET, + }, + }, +}); + +// The result field is `signedTransaction` (base64), not `transaction` +console.log('Signed transaction:', signResult.signedTransaction); +``` + +You can then broadcast the signed transaction yourself: + +```typescript +const signedBuffer = Buffer.from(signResult.signedTransaction, 'base64'); +const txId = await connection.sendRawTransaction(signedBuffer); +console.log('Transaction ID:', txId); +``` + +### Step 6: Sign and send a transaction (signAndSendTransaction) + +Signs and broadcasts the transaction in one step. + +```typescript +const sendResult = await client.invokeMethod({ + scope: SOLANA_MAINNET, + request: { + method: 'signAndSendTransaction', + params: { + account: { address: 'YourSolanaAddressBase58' }, + transaction: base64Transaction, + scope: SOLANA_MAINNET, + }, + }, +}); + +// sendResult: { signature: } +console.log('Transaction signature:', sendResult.signature); +``` + +### Step 7: Devnet transactions + +Connect with the devnet scope and point `@solana/web3.js` at the devnet cluster: + +```typescript +const SOLANA_DEVNET = 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1'; + +await client.connect([SOLANA_DEVNET], []); + +const connection = new Connection(clusterApiUrl('devnet')); + +// Build transaction with devnet connection... +const base64Tx = buildAndSerializeTransaction(connection); + +const result = await client.invokeMethod({ + scope: SOLANA_DEVNET, + request: { + method: 'signAndSendTransaction', + params: { + account: { address: 'YourSolanaAddressBase58' }, + transaction: base64Tx, + scope: SOLANA_DEVNET, + }, + }, +}); +``` + +### Step 8: Selective disconnect + +Disconnect only Solana scopes while keeping EVM sessions active: + +```typescript +// Disconnect only Solana mainnet — EVM scopes remain connected +await client.disconnect(['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp']); + +// Disconnect all scopes (full session teardown) +await client.disconnect(); +``` + +### Step 9: Error handling + +`invokeMethod` errors are wrapped in `RPCInvokeMethodErr` — its own `code` is always `53`, and the wallet's original EIP-1193 / JSON-RPC code (e.g. `4001` user rejection) is on `rpcCode`: + +```typescript +import { RPCInvokeMethodErr } from '@metamask/connect-multichain'; + +try { + await client.invokeMethod({ + scope: SOLANA_MAINNET, + request: { + method: 'signAndSendTransaction', + params: { + account: { address: 'YourSolanaAddressBase58' }, + transaction: base64Tx, + scope: SOLANA_MAINNET, + }, + }, + }); +} catch (err) { + if (err instanceof RPCInvokeMethodErr && err.rpcCode === 4001) { + // User rejected the transaction in MetaMask — not an app error + } else { + console.error('Solana transaction error:', err); + } +} +``` + +## Important Notes + +- **All Solana methods go to the wallet.** There is no RPC node routing for Solana — every `invokeMethod` call with a Solana scope prompts MetaMask. +- **Base64 encoding required.** Transactions and messages must be base64-encoded strings, not raw buffers or hex. +- **Use `@solana/web3.js` to build transactions.** Construct `Transaction` objects, set `recentBlockhash` and `feePayer`, serialize with `requireAllSignatures: false`, then base64-encode. +- **CAIP-2 genesis hash IDs.** Mainnet is `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp`. Devnet is `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1`. These are not cluster URLs — they are genesis hash identifiers. +- **Solana networks.** Mainnet, devnet, and testnet scopes are all modeled by the SDK; non-mainnet availability depends on the connected MetaMask build/version, so handle connection errors rather than assuming a cluster is present. +- **Selective disconnect preserves other scopes.** Passing specific Solana scopes to `disconnect()` only revokes those scopes. EVM scopes remain active. +- **Connected scopes required.** `invokeMethod` fails if the Solana scope was not included in the original `connect()` call. +- **Method names have no `solana_` prefix.** The MetaMask Multichain API methods are `signMessage`, `signTransaction`, and `signAndSendTransaction`, each taking `account: { address }` in params. (`solana_*`-prefixed names are WalletConnect's schema, not MetaMask's.) +- **`signTransaction` vs `signAndSendTransaction`:** Use `signTransaction` when you need to inspect or modify the signed output before broadcasting (result field: `signedTransaction`, base64). Use `signAndSendTransaction` for the common case where you want a single atomic operation (result field: `signature`, base58). diff --git a/skills/metamask-connect/workflows/send-evm-transaction.md b/skills/metamask-connect/workflows/send-evm-transaction.md new file mode 100644 index 00000000..72683ae3 --- /dev/null +++ b/skills/metamask-connect/workflows/send-evm-transaction.md @@ -0,0 +1,254 @@ +# Send EVM Transactions with MetaMask Connect + +## When to use + +Use this skill when: + +- Sending ETH transfers via `eth_sendTransaction` +- Calling smart contract functions by encoding `data` in the transaction +- Estimating gas with `eth_estimateGas` before sending +- Polling for transaction confirmation with `eth_getTransactionReceipt` +- Using the `connectWith` shortcut to connect and send in a single approval + +## Workflow + +### Step 1: Get the provider and connected account + +```typescript +import { createEVMClient, getInfuraRpcUrls } from '@metamask/connect-evm'; +import type { Hex, Address } from '@metamask/connect-evm'; + +const client = await createEVMClient({ + dapp: { name: 'My DApp', url: window.location.href }, + api: { + supportedNetworks: { + ...getInfuraRpcUrls({ + infuraApiKey: 'YOUR_INFURA_KEY', + chainIds: ['0x1', '0x89'], + }), + }, + }, +}); + +const { accounts } = await client.connect({ chainIds: ['0x1'] }); +const provider = client.getProvider(); +const from = accounts[0] as Address; +``` + +### Step 2: Convert ETH to hex wei + +All `value` fields in transactions must be hex-encoded wei. 1 ETH = 10^18 wei: + +```typescript +function ethToHexWei(ethAmount: string): Hex { + const wei = BigInt(Math.round(parseFloat(ethAmount) * 1e18)); + return `0x${wei.toString(16)}` as Hex; +} + +// Examples +ethToHexWei('0.01'); // '0x2386f26fc10000' +ethToHexWei('0.001'); // '0x38d7ea4c68000' +ethToHexWei('1'); // '0xde0b6b3a7640000' +``` + +### Step 3: Send an ETH transfer + +Build the transaction params and call `eth_sendTransaction`: + +```typescript +const txParams = { + from: from, + to: '0xRecipientAddress' as Address, + value: ethToHexWei('0.01'), +}; + +try { + const txHash = (await provider.request({ + method: 'eth_sendTransaction', + params: [txParams], + })) as Hex; + + console.log('Transaction hash:', txHash); +} catch (err: any) { + if (err.code === 4001) { + console.log('User rejected the transaction'); + return; + } + if (err.code === -32002) { + console.log('A transaction request is already pending'); + return; + } + throw err; +} +``` + +### Step 4: Estimate gas before sending + +Use `eth_estimateGas` to get a gas estimate, then optionally add a buffer: + +```typescript +const txParams = { + from: from, + to: '0xRecipientAddress' as Address, + value: ethToHexWei('0.01'), + data: '0x', // empty for plain ETH transfer +}; + +const estimatedGas = (await provider.request({ + method: 'eth_estimateGas', + params: [txParams], +})) as Hex; + +// Add 20% buffer to the estimate +const gasWithBuffer = (BigInt(estimatedGas) * 120n) / 100n; +const gasHex = `0x${gasWithBuffer.toString(16)}` as Hex; + +const txHash = (await provider.request({ + method: 'eth_sendTransaction', + params: [ + { + ...txParams, + gas: gasHex, + }, + ], +})) as Hex; +``` + +### Step 5: Send a contract interaction + +Encode the function call as the `data` field. For an ERC-20 `transfer(address,uint256)`: + +```typescript +// ERC-20 transfer function selector: 0xa9059cbb +// Encode: transfer(0xRecipient, 1000000) for USDC (6 decimals) +const recipient = '0xRecipientAddress'.slice(2).padStart(64, '0'); +const amount = (1000000).toString(16).padStart(64, '0'); // 1 USDC + +const data = `0xa9059cbb${recipient}${amount}` as Hex; + +const txHash = (await provider.request({ + method: 'eth_sendTransaction', + params: [ + { + from: from, + to: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Address, // USDC contract + data: data, + value: '0x0', // no ETH sent with token transfer + }, + ], +})) as Hex; +``` + +### Step 6: Poll for transaction receipt + +After sending, poll `eth_getTransactionReceipt` until the transaction is confirmed: + +```typescript +async function waitForReceipt( + provider: any, + txHash: Hex, + intervalMs = 2000, + timeoutMs = 120000, +): Promise { + const start = Date.now(); + + while (Date.now() - start < timeoutMs) { + const receipt = await provider.request({ + method: 'eth_getTransactionReceipt', + params: [txHash], + }); + + if (receipt !== null) { + // receipt.status: '0x1' = success, '0x0' = revert + return receipt; + } + + await new Promise((r) => setTimeout(r, intervalMs)); + } + + throw new Error(`Transaction ${txHash} not confirmed within ${timeoutMs}ms`); +} + +// Usage +const txHash = (await provider.request({ + method: 'eth_sendTransaction', + params: [txParams], +})) as Hex; + +const receipt = await waitForReceipt(provider, txHash); + +if (receipt.status === '0x1') { + console.log( + 'Transaction confirmed in block:', + parseInt(receipt.blockNumber, 16), + ); +} else { + console.error('Transaction reverted'); +} +``` + +### Step 7: Use connectWith for single-approval flow + +`connectWith` connects the wallet and sends a transaction in one user interaction: + +```typescript +const { accounts, chainId, result } = await client.connectWith({ + method: 'eth_sendTransaction', + // The params function receives the FIRST connected account (a single + // Address), not the accounts array + params: (account: Address) => [ + { + from: account, + to: '0xRecipientAddress' as Address, + value: ethToHexWei('0.01'), + }, + ], + chainIds: ['0x1'], +}); + +// result is the transaction hash +const txHash = result as Hex; +console.log('Connected as:', accounts[0]); +console.log('Transaction hash:', txHash); +``` + +The `params` field accepts a function that receives the first connected account (`(account: Address) => unknown[]`), letting you use the connected address as `from` without knowing it ahead of time. + +### Step 8: Handle errors + +```typescript +try { + const txHash = await provider.request({ + method: 'eth_sendTransaction', + params: [txParams], + }); +} catch (err: any) { + switch (err.code) { + case 4001: + // User rejected the transaction — offer retry + break; + case -32002: + // Request already pending — wait for user action in MetaMask + break; + case -32000: + // Execution error (insufficient funds, gas too low, etc.) + console.error('Execution error:', err.message); + break; + default: + console.error('Transaction failed:', err); + } +} +``` + +## Important Notes + +- **`value` must be hex-encoded wei** — `'0xde0b6b3a7640000'` is 1 ETH. Never pass decimal strings or ETH-denominated numbers directly. +- **`from` must match the connected account** — MetaMask rejects transactions where `from` doesn't match the active account. +- **`eth_sendTransaction` returns a transaction hash, not a receipt** — poll `eth_getTransactionReceipt` to confirm the transaction was mined. +- **Receipt `status` is hex** — `'0x1'` means success, `'0x0'` means the transaction was mined but reverted. +- **`eth_estimateGas` can throw** — if the transaction would revert, estimation fails. Wrap it in a try/catch and show the error to the user. +- **`connectWith` params can be a function** — `params: (account) => [{ from: account, ... }]` — it receives the first connected account (a single `Address`, not an array). +- **Chain IDs are always hex strings in SDK calls** — `'0x1'`, `'0x89'`, `'0xaa36a7'`. The `chainId` in transaction objects follows the same convention when present. +- **Error code 4001** means the user deliberately rejected — handle gracefully. +- **Error code -32002** means a request is pending — do not send another transaction. +- **`0x1` is auto-included** in every `connect()` / `connectWith()` call. diff --git a/skills/metamask-connect/workflows/send-solana-transaction.md b/skills/metamask-connect/workflows/send-solana-transaction.md new file mode 100644 index 00000000..f25c2f71 --- /dev/null +++ b/skills/metamask-connect/workflows/send-solana-transaction.md @@ -0,0 +1,291 @@ +# Send Solana Transaction with MetaMask + +## When to use + +Use this skill when: + +- Sending SOL or interacting with Solana programs via MetaMask Connect +- Building a `Transaction` with `@solana/web3.js` and submitting it through the wallet +- Using `sendTransaction` from `useWallet` in a React app +- Using the `solana:signAndSendTransaction` wallet-standard feature in a vanilla browser app + +## Workflow + +### Step 1: Build the transaction + +Use `@solana/web3.js` to construct the transaction. Every transaction needs a recent blockhash and a fee payer. + +```typescript +import { + Connection, + Transaction, + SystemProgram, + PublicKey, + LAMPORTS_PER_SOL, +} from '@solana/web3.js'; + +const connection = new Connection('https://api.mainnet-beta.solana.com'); +const { blockhash } = await connection.getLatestBlockhash(); + +const senderPubkey = new PublicKey('SENDER_PUBLIC_KEY'); +const recipientPubkey = new PublicKey('RECIPIENT_PUBLIC_KEY'); + +const transaction = new Transaction().add( + SystemProgram.transfer({ + fromPubkey: senderPubkey, + toPubkey: recipientPubkey, + lamports: 0.001 * LAMPORTS_PER_SOL, + }), +); +transaction.recentBlockhash = blockhash; +transaction.feePayer = senderPubkey; +``` + +### Step 2a: Send with React wallet-adapter (useWallet) + +**Prerequisites:** `createSolanaClient` has been awaited before rendering, `WalletProvider` is configured with `wallets={[]}`, and the user is connected. See [`setup-solana-react.md`](setup-solana-react.md). + +```tsx +import { useWallet, useConnection } from '@solana/wallet-adapter-react'; +import { + Transaction, + SystemProgram, + PublicKey, + LAMPORTS_PER_SOL, +} from '@solana/web3.js'; + +function SendTransactionButton() { + const { publicKey, sendTransaction, connected } = useWallet(); + const { connection } = useConnection(); + + const handleSend = async () => { + if (!publicKey || !sendTransaction) return; + + try { + const { blockhash } = await connection.getLatestBlockhash(); + + const transaction = new Transaction().add( + SystemProgram.transfer({ + fromPubkey: publicKey, + toPubkey: new PublicKey('RECIPIENT_PUBLIC_KEY'), + lamports: 0.001 * LAMPORTS_PER_SOL, + }), + ); + transaction.recentBlockhash = blockhash; + transaction.feePayer = publicKey; + + const signature = await sendTransaction(transaction, connection); + console.log('Transaction submitted:', signature); + + const confirmation = await connection.confirmTransaction( + signature, + 'confirmed', + ); + console.log('Transaction confirmed:', confirmation); + } catch (err: any) { + if (err.code === 4001) { + console.log('User rejected the transaction'); + return; + } + console.error('Transaction failed:', err); + } + }; + + return ( + + ); +} +``` + +### Step 2b: Send with vanilla browser (wallet-standard feature) + +**Prerequisites:** `createSolanaClient` has been called and the wallet is connected via `standard:connect`. See [`setup-solana-browser.md`](setup-solana-browser.md). + +```typescript +import { createSolanaClient } from '@metamask/connect-solana'; +import { + Connection, + Transaction, + SystemProgram, + PublicKey, + LAMPORTS_PER_SOL, +} from '@solana/web3.js'; + +const solanaClient = await createSolanaClient({ + dapp: { name: 'My DApp', url: window.location.href }, +}); + +const wallet = solanaClient.getWallet(); + +// Connect first +const connectFeature = wallet.features['standard:connect']; +const { accounts } = await connectFeature.connect(); +const account = accounts[0]; +const senderPubkey = new PublicKey(account.address); + +// Build the transaction +const connection = new Connection('https://api.mainnet-beta.solana.com'); +const { blockhash } = await connection.getLatestBlockhash(); + +const transaction = new Transaction().add( + SystemProgram.transfer({ + fromPubkey: senderPubkey, + toPubkey: new PublicKey('RECIPIENT_PUBLIC_KEY'), + lamports: 0.001 * LAMPORTS_PER_SOL, + }), +); +transaction.recentBlockhash = blockhash; +transaction.feePayer = senderPubkey; + +// Serialize and send +const serializedTransaction = transaction.serialize({ + requireAllSignatures: false, +}); + +const signAndSendFeature = wallet.features['solana:signAndSendTransaction']; + +// The `chain` field accepts the wallet-standard short forms ('solana:mainnet', +// 'solana:devnet', 'solana:testnet') or the full genesis-hash CAIP-2 scope +// (e.g. 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'). It is optional and +// defaults to mainnet. Bare 'mainnet' (without the 'solana:' prefix) is +// INVALID here and throws 'Unsupported chainId' — the SolanaNetwork short +// names apply only to createSolanaClient's api.supportedNetworks keys. + +// Requires `npm install bs58` and `import bs58 from 'bs58'` at the top of the file +try { + const [{ signature }] = await signAndSendFeature.signAndSendTransaction({ + account, + transaction: serializedTransaction, + chain: 'solana:mainnet', // wallet-standard short form; full genesis-hash CAIP-2 IDs also accepted + }); + + // signature is a Uint8Array — encode with bs58 ('base58' is NOT a Buffer encoding) + const signatureBase58 = bs58.encode(signature); + console.log('Transaction submitted:', signatureBase58); + + // confirmTransaction expects the base58 signature string + const confirmation = await connection.confirmTransaction( + signatureBase58, + 'confirmed', + ); + console.log('Transaction confirmed:', confirmation); +} catch (err: any) { + if (err.code === 4001) { + console.log('User rejected the transaction'); + } else { + console.error('Transaction failed:', err); + } +} +``` + +### Step 3: Sign without sending (sign-only flow) + +If you need to sign a transaction without broadcasting it (e.g., for offline signing or multi-sig): + +**React:** + +```typescript +const { signTransaction } = useWallet(); + +if (signTransaction) { + const signedTransaction = await signTransaction(transaction); + // signedTransaction is now signed but NOT sent — broadcast manually if needed + const rawTransaction = signedTransaction.serialize(); + const signature = await connection.sendRawTransaction(rawTransaction); +} +``` + +**Browser (wallet-standard):** + +```typescript +const signFeature = wallet.features['solana:signTransaction']; + +const [{ signedTransaction }] = await signFeature.signTransaction({ + account, + transaction: transaction.serialize({ requireAllSignatures: false }), + chain: 'solana:mainnet', +}); + +// signedTransaction is serialized and signed — broadcast manually +const signature = await connection.sendRawTransaction(signedTransaction); +``` + +### Step 4: Send multiple transactions (batch) + +There is **no** `solana:signAndSendAllTransactions` feature. The `solana:signAndSendTransaction` feature is **variadic** — pass multiple inputs and it returns one result per input: + +```typescript +const signAndSendFeature = wallet.features['solana:signAndSendTransaction']; + +const results = await signAndSendFeature.signAndSendTransaction( + { account, transaction: serializedTx1, chain: 'solana:mainnet' }, + { account, transaction: serializedTx2, chain: 'solana:mainnet' }, + { account, transaction: serializedTx3, chain: 'solana:mainnet' }, +); + +for (const { signature } of results) { + console.log('Tx signature:', bs58.encode(signature)); +} +``` + +### Step 5: Confirm the transaction + +Always confirm after sending. Use `'confirmed'` commitment for most use cases: + +```typescript +const confirmation = await connection.confirmTransaction( + signature, + 'confirmed', +); + +if (confirmation.value.err) { + console.error('Transaction failed on-chain:', confirmation.value.err); +} else { + console.log('Transaction succeeded'); +} +``` + +### Step 6: Error handling + +| Error | Cause | Action | +| ----------------------- | ------------------------------------------ | --------------------------------- | +| Code `4001` | User rejected in MetaMask | Show retry UI | +| Code `-32002` | Request already pending | Wait for user to act in MetaMask | +| `Blockhash not found` | Blockhash expired before submission | Fetch a new blockhash and rebuild | +| `Insufficient funds` | Sender balance too low for transfer + fees | Show balance check UI | +| `Transaction too large` | Transaction exceeds 1232 bytes | Split into multiple transactions | + +```typescript +try { + const signature = await sendTransaction(transaction, connection); + await connection.confirmTransaction(signature, 'confirmed'); +} catch (err: any) { + switch (err.code) { + case 4001: + // User rejected + break; + case -32002: + // Pending — ask user to check MetaMask + break; + default: + if (err.message?.includes('Blockhash not found')) { + // Retry with fresh blockhash + } + console.error('Transaction error:', err); + } +} +``` + +## Important Notes + +- **Always fetch a fresh blockhash** — blockhashes expire after ~60 seconds. Fetch `getLatestBlockhash()` immediately before building the transaction, not at app startup. +- **Set `feePayer` before signing** — the transaction must have `feePayer` and `recentBlockhash` set before it is signed or serialized. +- **Serialize with `requireAllSignatures: false`** — when using wallet-standard features directly, the wallet will add the signature. Serializing with `requireAllSignatures: true` (the default) will throw because the transaction isn't signed yet. +- **`sendTransaction` vs `signAndSendTransaction`** — in the React adapter, `sendTransaction` handles serialization internally. With wallet-standard features, you must serialize the transaction yourself and pass the bytes. +- **`chain` is only honored by `signAndSendTransaction`** — `signAndSendTransaction` reads the input's `chain` to pick the cluster, but `signTransaction`/`signMessage` ignore any `chain` field and use the **connected session scope** instead. Passing `chain` to `signTransaction` is harmless but doesn't switch networks; connect with the scope you want to sign on. +- **Solana networks** — mainnet, devnet, and testnet scopes are all modeled by the SDK; non-mainnet availability depends on the connected MetaMask build/version, so handle connection errors rather than assuming a cluster is present. +- **`disconnect()` only revokes Solana scopes** — EVM sessions remain active. +- **Chrome on Android** — apply the `beforeunload` workaround for the known page-unload bug during wallet interactions. +- **Confirm before reporting success** — a submitted transaction is not finalized until `confirmTransaction` returns. Always confirm before updating the UI. diff --git a/skills/metamask-connect/workflows/setup-evm-browser.md b/skills/metamask-connect/workflows/setup-evm-browser.md new file mode 100644 index 00000000..69153a8b --- /dev/null +++ b/skills/metamask-connect/workflows/setup-evm-browser.md @@ -0,0 +1,299 @@ +# Setup EVM Browser App with MetaMask Connect + +## When to use + +Use this skill when: + +- Building a vanilla JavaScript or TypeScript browser app (no React) with MetaMask +- Integrating `createEVMClient` into a plain HTML page or a bundler-based project +- Wiring up EIP-1193 provider event listeners for account and chain changes +- Performing RPC calls through `provider.request` in a non-framework context + +## Workflow + +### Step 1: Install dependencies + +```bash +npm install @metamask/connect-evm +``` + +`@metamask/connect-multichain` is a regular dependency of `@metamask/connect-evm` and is installed transitively. (Only the 2.0.0 release briefly made it a peer dependency; 2.1.0 reverted that.) Installing it explicitly is harmless but not required. The SDK warns at runtime if duplicate or mismatched copies are resolved. + +Or include via CDN/script tag if not using a bundler. + +### Step 2: Create the EVM client + +```typescript +import { createEVMClient, getInfuraRpcUrls } from '@metamask/connect-evm'; + +const client = await createEVMClient({ + dapp: { + name: 'My Browser DApp', + url: window.location.href, + }, + api: { + supportedNetworks: { + ...getInfuraRpcUrls({ + infuraApiKey: 'YOUR_INFURA_KEY', + chainIds: ['0x1', '0x89', '0xa4b1', '0xaa36a7'], + }), + }, + }, + ui: { + headless: false, + preferExtension: true, + showInstallModal: true, + }, + eventHandlers: { + // Keys are camelCase — `display_uri`/`wallet_sessionChanged` are NOT valid here + displayUri: (uri: string) => { + console.log('QR URI:', uri); + // Render QR code for mobile connection + }, + connect: ({ accounts, chainId }) => { + // Fires on connection and on automatic session restore + updateUI(accounts, chainId); + }, + }, + debug: false, +}); +``` + +There is no `wallet_sessionChanged` handler on the EVM client — session restores surface through the `connect` handler / provider event and `accountsChanged`. (`wallet_sessionChanged` is a multichain-client event.) + +### Step 3: Register provider event listeners + +Set up EIP-1193 event listeners immediately after client creation: + +```typescript +const provider = client.getProvider(); + +provider.on('accountsChanged', (accounts: string[]) => { + if (accounts.length === 0) { + // User disconnected their wallet + updateUI([], null); + return; + } + updateUI(accounts, null); + fetchBalance(accounts[0]); +}); + +provider.on('chainChanged', (chainId: string) => { + // chainId is a hex string, e.g. '0x1' + document.getElementById('chain')!.textContent = `Chain: ${chainId}`; + // Refresh balances since the chain changed + const currentAccount = document.getElementById('account')?.dataset.address; + if (currentAccount) fetchBalance(currentAccount); +}); + +provider.on('disconnect', () => { + // The connect-evm provider emits `disconnect` with no payload + console.log('Disconnected'); + updateUI([], null); +}); +``` + +### Step 4: Connect and update UI + +```typescript +const connectBtn = document.getElementById('connect-btn')!; +const disconnectBtn = document.getElementById('disconnect-btn')!; + +connectBtn.addEventListener('click', async () => { + try { + connectBtn.textContent = 'Connecting...'; + connectBtn.setAttribute('disabled', 'true'); + + const { accounts, chainId } = await client.connect({ + chainIds: ['0x1'], + }); + + updateUI(accounts, chainId); + } catch (err: any) { + if (err.code === 4001) { + showError('Connection rejected. Click Connect to try again.'); + return; + } + if (err.code === -32002) { + showError('A connection request is already pending. Check MetaMask.'); + return; + } + showError(err.message ?? 'Connection failed'); + } finally { + connectBtn.textContent = 'Connect MetaMask'; + connectBtn.removeAttribute('disabled'); + } +}); + +disconnectBtn.addEventListener('click', async () => { + await client.disconnect(); + updateUI([], null); +}); + +function updateUI(accounts: string[], chainId: string | null) { + const accountEl = document.getElementById('account')!; + const chainEl = document.getElementById('chain')!; + const connectedSection = document.getElementById('connected')!; + + if (accounts.length === 0) { + connectedSection.style.display = 'none'; + connectBtn.style.display = 'block'; + return; + } + + accountEl.textContent = `Account: ${accounts[0]}`; + accountEl.dataset.address = accounts[0]; + if (chainId) chainEl.textContent = `Chain: ${chainId}`; + connectedSection.style.display = 'block'; + connectBtn.style.display = 'none'; +} + +function showError(message: string) { + const errorEl = document.getElementById('error')!; + errorEl.textContent = message; + setTimeout(() => { + errorEl.textContent = ''; + }, 5000); +} +``` + +### Step 5: Make RPC calls via provider.request + +```typescript +const provider = client.getProvider(); + +// Cached/intercepted reads — safe before connect (no chain selection needed) +const chainId = await provider.request({ method: 'eth_chainId' }); +const accounts = await provider.request({ method: 'eth_accounts' }); + +// Node-routed reads need a SELECTED chain — they throw `No chain ID selected` +// until after connect() (or a restored session). Call these post-connect. +const blockNumber = await provider.request({ method: 'eth_blockNumber' }); + +async function fetchBalance(address: string) { + const wei = (await provider.request({ + method: 'eth_getBalance', + params: [address, 'latest'], + })) as string; + + const ethBalance = parseInt(wei, 16) / 1e18; + document.getElementById('balance')!.textContent = + `Balance: ${ethBalance.toFixed(6)} ETH`; +} + +// Get gas price +const gasPrice = await provider.request({ method: 'eth_gasPrice' }); + +// Get transaction count (nonce) +const nonce = await provider.request({ + method: 'eth_getTransactionCount', + params: [accounts[0], 'latest'], +}); + +// Call a contract (read-only) +const result = await provider.request({ + method: 'eth_call', + params: [ + { + to: '0xContractAddress', + data: '0xEncodedFunctionSelector', + }, + 'latest', + ], +}); +``` + +### Step 6: Switch chains with chainConfiguration fallback + +```typescript +async function switchChain(targetChainId: string) { + try { + await client.switchChain({ chainId: targetChainId }); + } catch (err: any) { + if (err.code === 4001) { + showError('Chain switch rejected by user.'); + } + } +} + +// Switch to a chain with fallback configuration +// chainConfiguration triggers wallet_addEthereumChain if the chain +// is not already configured in the user's wallet +async function switchToArbitrum() { + try { + await client.switchChain({ + chainId: '0xa4b1', + chainConfiguration: { + chainId: '0xa4b1', // optional in the type, but set it to the target chain — if omitted it falls back to the currently selected chain (likely the wrong chain to add) + chainName: 'Arbitrum One', + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + rpcUrls: ['https://arb1.arbitrum.io/rpc'], + blockExplorerUrls: ['https://arbiscan.io'], + }, + }); + } catch (err: any) { + if (err.code === 4001) { + showError('User rejected the chain addition or switch.'); + } + } +} + +// Switch to well-known chains +document + .getElementById('switch-mainnet')! + .addEventListener('click', () => switchChain('0x1')); +document + .getElementById('switch-polygon')! + .addEventListener('click', () => switchChain('0x89')); +document + .getElementById('switch-sepolia')! + .addEventListener('click', () => switchChain('0xaa36a7')); +document + .getElementById('switch-arbitrum')! + .addEventListener('click', () => switchToArbitrum()); +``` + +### Step 7: Complete HTML structure + +```html + + + + + MetaMask Connect + + +

MetaMask Connect Demo

+ + +

+ + + + + + +``` + +## Important Notes + +- **Call `createEVMClient` once at app startup** — each call returns a _new_ EVM client wrapper, but they all share one underlying multichain core (the core is the singleton whose options merge across calls). Don't recreate the client repeatedly. +- **Chain IDs are always hex strings** — use `'0x1'`, `'0x89'`, `'0xaa36a7'`. Never pass decimal numbers or decimal strings. +- **`0x1` (Ethereum mainnet) is auto-included** in every `connect()` call regardless of the `chainIds` you specify. +- **The provider exists before connection** — `client.getProvider()` always returns a valid EIP-1193 provider. But node-routed reads (`eth_blockNumber`, `eth_getBalance`, `eth_call`, …) require a **selected chain** and throw `No chain ID selected` until one is set (after `connect()` or a restored session). Only `eth_chainId` and `eth_accounts` (intercepted, served from cache) are safe before connecting. +- **Convenience getters** — use `client.getChainId()` (returns `Hex | undefined`) and `client.getAccount()` (returns `Address | undefined`) instead of `provider.request({ method: 'eth_chainId' })` / `eth_accounts` for cached state. +- **Connection status** — `client.status` returns `'connecting'` | `'connected'` | `'disconnected'`. (The 5-value `'loaded'`/`'pending'` union belongs to the multichain core, not the EVM client.) Use this for UI state instead of tracking manually. +- **Register event listeners before connecting** — set up `accountsChanged`, `chainChanged`, and `disconnect` handlers immediately after getting the provider. +- **`chainConfiguration` is a fallback, not a forced add** — it is only used if the wallet doesn't already have the chain configured. If the chain exists, only `wallet_switchEthereumChain` fires. +- **Page reloads restore automatically** — the EVM client syncs any persisted session before `createEVMClient` resolves and re-emits `connect`/`accountsChanged` on the provider. The EVM client has no `.on()` method and no `wallet_sessionChanged` handler — use the provider events (or `eventHandlers.connect`) to restore UI state. +- **Error code 4001** means the user deliberately rejected — show a retry option, not a crash screen. +- **Error code -32002** means a request is already pending — do not send another `connect()`. Wait for the user to respond in MetaMask. diff --git a/skills/metamask-connect/workflows/setup-evm-react-native.md b/skills/metamask-connect/workflows/setup-evm-react-native.md new file mode 100644 index 00000000..5378a62c --- /dev/null +++ b/skills/metamask-connect/workflows/setup-evm-react-native.md @@ -0,0 +1,332 @@ +# Setup EVM React Native App with MetaMask Connect + +## When to use + +Use this skill when: + +- Creating a React Native app that connects to MetaMask Mobile +- Setting up polyfills for window and other missing globals +- Configuring metro.config.js with Node.js module shims +- Debugging React Native import order or missing polyfill errors + +## Workflow + +### Step 1: Install dependencies + +Install the SDK and all required polyfill/shim packages: + +```bash +npm install @metamask/connect-evm @metamask/connect-multichain react-native-get-random-values buffer @react-native-async-storage/async-storage readable-stream +``` + +`@metamask/connect-multichain` is installed transitively by `@metamask/connect-evm` (only the 2.0.0 release briefly made it a peer dependency; 2.1.0 reverted that) — installing it explicitly is harmless but not required. The SDK warns at runtime on duplicate or mismatched copies. `react-native-get-random-values` provides `crypto.getRandomValues` — strictly required only on React Native < 0.72 (Hermes 0.72+ ships `globalThis.crypto.getRandomValues` natively), but recommended as a safety net on all versions. It **must** be imported before any other SDK-related code. `readable-stream` provides the `stream` shim for Metro. `buffer` is recommended as a safety net for peer dependencies — `@metamask/connect-multichain` self-polyfills Buffer internally, but other deps (e.g. `eciesjs`) may load before it. `@react-native-async-storage/async-storage` is needed for session persistence. + +### Step 2: Create polyfills.ts + +Create `src/polyfills.ts` with all required global shims. This file must be imported before anything else: + +```typescript +// src/polyfills.ts +// IMPORTANT: react-native-get-random-values must be imported in the +// entry file BEFORE this polyfills file. See Step 4. + +import { Buffer } from 'buffer'; + +// Buffer global — connect-multichain self-polyfills this, but set it here +// as a safety net for other deps that may load before connect-multichain. +global.Buffer = Buffer; + +// window object — required for correct platform detection and deeplink behaviour. +// connect-multichain inspects window.navigator.product and window.location to +// determine platform type and whether to use deeplinks vs install modal. +const eventListeners = new Map>(); +if (typeof global.window === 'undefined') { + (global as any).window = { + location: { + hostname: 'my-rn-app', + href: 'https://my-rn-app.local', + }, + navigator: { product: 'ReactNative' }, + addEventListener: (event: string, listener: EventListener) => { + if (!eventListeners.has(event)) eventListeners.set(event, new Set()); + eventListeners.get(event)?.add(listener); + }, + removeEventListener: (event: string, listener: EventListener) => { + eventListeners.get(event)?.delete(listener); + }, + dispatchEvent: (_event: Event) => true, + }; +} + +// NOTE: Event and CustomEvent polyfills are NOT needed for standalone +// @metamask/connect-evm usage — the SDK uses eventemitter3 internally. +// Add them only if you are also using wagmi (wagmi dispatches DOM events). +``` + +### Step 3: Configure metro.config.js + +Metro cannot resolve Node.js built-in modules. Map them to React Native-compatible shims or an empty module: + +```javascript +// metro.config.js +const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config'); +const path = require('path'); + +// Create a path to an empty module for stubs +const emptyModule = path.resolve(__dirname, 'src/empty-module.js'); + +const config = { + 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, + }, + }, +}; + +module.exports = mergeConfig(getDefaultConfig(__dirname), config); +``` + +Create the empty module stub: + +```javascript +// src/empty-module.js +module.exports = {}; +``` + +### Step 4: Set up the entry file with correct import order + +The import order is critical. `react-native-get-random-values` **must** be the very first import: + +```typescript +// index.js or App.tsx (entry file) +import 'react-native-get-random-values'; // MUST be first +import './src/polyfills'; // MUST be second +import { AppRegistry } from 'react-native'; +import App from './src/App'; +import { name as appName } from './app.json'; + +AppRegistry.registerComponent(appName, () => App); +``` + +If you import anything from `@metamask/connect-evm` before `react-native-get-random-values`, you will get `crypto.getRandomValues is not a function`. + +### Step 5: Create the EVM client with mobile configuration + +```typescript +// src/metamask.ts +import { createEVMClient, getInfuraRpcUrls } from '@metamask/connect-evm'; +import { Linking } from 'react-native'; +import type { MetamaskConnectEVM } from '@metamask/connect-evm'; + +let clientPromise: Promise | null = null; + +export function getClient(): Promise { + if (!clientPromise) { + clientPromise = createEVMClient({ + dapp: { + name: 'My RN DApp', + url: 'https://mydapp.com', + }, + api: { + supportedNetworks: { + ...getInfuraRpcUrls({ + infuraApiKey: 'YOUR_INFURA_KEY', + chainIds: ['0x1', '0x89'], + }), + }, + }, + ui: { + preferExtension: false, + }, + mobile: { + preferredOpenLink: (deeplink: string) => Linking.openURL(deeplink), + useDeeplink: true, + }, + eventHandlers: { + // Keys are camelCase — `display_uri`/`wallet_sessionChanged` are NOT valid here + displayUri: (uri: string) => { + console.log('Deeplink URI:', uri); + }, + connect: ({ accounts, chainId }) => { + // Fires on connection and on automatic session restore at relaunch + console.log('Connected/restored:', accounts, chainId); + }, + }, + debug: false, + }); + } + return clientPromise; +} +``` + +`mobile.preferredOpenLink` is **required** for React Native — it tells the SDK how to open deeplinks to the MetaMask Mobile app. Without it, the connection flow will hang silently. + +### Step 6: Build the React Native component + +```tsx +// src/WalletScreen.tsx +import React, { useEffect, useRef, useState, useCallback } from 'react'; +import { View, Text, TouchableOpacity, StyleSheet, Alert } from 'react-native'; +import { getClient } from './metamask'; +import type { MetamaskConnectEVM } from '@metamask/connect-evm'; +import type { Hex, Address } from '@metamask/connect-evm'; + +export function WalletScreen() { + const clientRef = useRef(null); + const [accounts, setAccounts] = useState([]); + const [chainId, setChainId] = useState(null); + const [balance, setBalance] = useState(''); + const [connecting, setConnecting] = useState(false); + + useEffect(() => { + let mounted = true; + + async function init() { + const client = await getClient(); + if (!mounted) return; + clientRef.current = client; + + const provider = client.getProvider(); + + provider.on('accountsChanged', (accs: Address[]) => { + if (mounted) setAccounts(accs); + }); + + provider.on('chainChanged', (id: Hex) => { + if (mounted) setChainId(id); + }); + + provider.on('disconnect', () => { + if (mounted) { + setAccounts([]); + setChainId(null); + setBalance(''); + } + }); + } + + init(); + return () => { + mounted = false; + }; + }, []); + + const handleConnect = useCallback(async () => { + const client = clientRef.current; + if (!client) return; + + setConnecting(true); + try { + const result = await client.connect({ chainIds: ['0x1'] }); + setAccounts(result.accounts as Address[]); + setChainId(result.chainId as Hex); + } catch (err: any) { + if (err.code === 4001) { + Alert.alert('Rejected', 'Connection was rejected. Please try again.'); + return; + } + if (err.code === -32002) { + Alert.alert('Pending', 'A request is already pending. Check MetaMask.'); + return; + } + Alert.alert('Error', err.message ?? 'Connection failed'); + } finally { + setConnecting(false); + } + }, []); + + const handleDisconnect = useCallback(async () => { + const client = clientRef.current; + if (!client) return; + await client.disconnect(); + setAccounts([]); + setChainId(null); + setBalance(''); + }, []); + + const fetchBalance = useCallback(async () => { + const client = clientRef.current; + if (!client || accounts.length === 0) return; + + const provider = client.getProvider(); + const wei = (await provider.request({ + method: 'eth_getBalance', + params: [accounts[0], 'latest'], + })) as Hex; + + const ethBalance = parseInt(wei, 16) / 1e18; + setBalance(ethBalance.toFixed(6)); + }, [accounts]); + + const isConnected = accounts.length > 0; + + return ( + + {!isConnected ? ( + + + {connecting ? 'Connecting...' : 'Connect MetaMask'} + + + ) : ( + + Account: {accounts[0]} + Chain: {chainId} + Balance: {balance || '—'} ETH + + Refresh Balance + + + Disconnect + + + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 20, + }, + button: { + backgroundColor: '#037DD6', + padding: 14, + borderRadius: 8, + marginVertical: 8, + }, + buttonText: { color: '#fff', fontSize: 16, textAlign: 'center' }, + label: { fontSize: 14, marginVertical: 4 }, +}); +``` + +## Important Notes + +- **Import order is critical** — `react-native-get-random-values` must be the very first import in the entry file, followed by `polyfills.ts`, before any SDK or application code. +- **`mobile.preferredOpenLink` is required** — without it, the SDK cannot open deeplinks to MetaMask Mobile and the connection flow will silently fail. +- **`ui.preferExtension` should be `false`** — React Native has no browser extension. Setting this to `false` (or omitting it) ensures the SDK uses the mobile deeplink/QR flow. +- **Chain IDs are always hex strings** — use `'0x1'`, `'0x89'`, `'0xaa36a7'`. Never decimal. +- **`0x1` is auto-included** in every `connect()` call. +- **The empty module stub** (`src/empty-module.js`) is used for Node built-ins the SDK's transitive dependencies reference but never actually call at runtime in React Native. The `stream` module is the exception — it needs a real shim (`readable-stream`). +- **`createEVMClient` is a singleton** — do not call it on every render or in a component body. Initialize once and store the promise. +- **Session restoration** — the EVM client syncs any persisted session before `createEVMClient` resolves; detect restores via the `connect` / `accountsChanged` events (in `eventHandlers` or on the provider). There is no `wallet_sessionChanged` handler on the EVM client — that event belongs to the multichain client. +- **iOS requires `Linking` permissions** — ensure your `Info.plist` includes the `metamask` URL scheme in `LSApplicationQueriesSchemes` so `Linking.openURL` can open the MetaMask app. diff --git a/skills/metamask-connect/workflows/setup-evm-react.md b/skills/metamask-connect/workflows/setup-evm-react.md new file mode 100644 index 00000000..6520e6a4 --- /dev/null +++ b/skills/metamask-connect/workflows/setup-evm-react.md @@ -0,0 +1,300 @@ +# Setup EVM React App with MetaMask Connect + +## When to use + +Use this skill when: + +- Creating a new React app that connects to MetaMask via `@metamask/connect-evm` +- Adding wallet connect, sign, or send functionality to an existing React app +- Setting up `createEVMClient` with Infura RPC URLs and event handlers +- Building a React component that tracks accounts, chain, and balance state + +## Workflow + +### Step 1: Install dependencies + +```bash +npm install @metamask/connect-evm @metamask/connect-multichain +``` + +`@metamask/connect-multichain` is a regular dependency of `@metamask/connect-evm` and is installed transitively. (Only the 2.0.0 release briefly made it a peer dependency; 2.1.0 reverted that.) Installing it explicitly is harmless but not required. The SDK warns at runtime if duplicate or mismatched copies are resolved. + +### Step 2: Create the EVM client + +Create a module that initializes the client once and exports a ready promise: + +```typescript +// src/metamask.ts +import { createEVMClient, getInfuraRpcUrls } from '@metamask/connect-evm'; +import type { MetamaskConnectEVM } from '@metamask/connect-evm'; + +let clientPromise: Promise | null = null; + +export function getClient(): Promise { + if (!clientPromise) { + clientPromise = createEVMClient({ + dapp: { + name: 'My React DApp', + url: window.location.href, + }, + api: { + supportedNetworks: { + ...getInfuraRpcUrls({ + infuraApiKey: 'YOUR_INFURA_KEY', + chainIds: ['0x1', '0x89', '0xaa36a7'], + }), + '0xa4b1': 'https://arb1.arbitrum.io/rpc', + }, + }, + ui: { + headless: false, + preferExtension: true, + showInstallModal: true, + }, + eventHandlers: { + displayUri: (uri: string) => { + console.log('QR URI:', uri); + }, + }, + debug: false, + }); + } + return clientPromise; +} +``` + +`getInfuraRpcUrls({ infuraApiKey, chainIds? })` returns a `Record` mapping hex chain IDs to Infura RPC URLs for all Infura-supported EVM chains. Pass an optional `chainIds` array (hex strings, e.g. `['0x1', '0x89']`) to limit the output to specific chains. Spread it into `supportedNetworks` and add custom RPCs for any additional chains. + +### Step 3: Build the wallet component + +Use `useRef` to hold the client instance and `useState` for reactive UI state: + +```tsx +// src/WalletConnect.tsx +import { useEffect, useRef, useState, useCallback } from 'react'; +import { getClient } from './metamask'; +import type { MetamaskConnectEVM } from '@metamask/connect-evm'; +import type { Hex, Address } from '@metamask/connect-evm'; + +export function WalletConnect() { + const clientRef = useRef(null); + const [accounts, setAccounts] = useState([]); + const [chainId, setChainId] = useState(null); + const [balance, setBalance] = useState(''); + const [connecting, setConnecting] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + let mounted = true; + + async function init() { + const client = await getClient(); + if (!mounted) return; + clientRef.current = client; + + const provider = client.getProvider(); + + provider.on('accountsChanged', (accs: Address[]) => { + if (mounted) setAccounts(accs); + }); + + provider.on('chainChanged', (id: Hex) => { + if (mounted) setChainId(id); + }); + + provider.on('disconnect', () => { + if (mounted) { + setAccounts([]); + setChainId(null); + setBalance(''); + } + }); + } + + init(); + return () => { + mounted = false; + }; + }, []); + + const handleConnect = useCallback(async () => { + const client = clientRef.current; + if (!client) return; + + setConnecting(true); + setError(null); + + try { + const result = await client.connect({ chainIds: ['0x1'] }); + setAccounts(result.accounts as Address[]); + setChainId(result.chainId as Hex); + } catch (err: any) { + if (err.code === 4001) { + setError('Connection rejected. Please try again.'); + return; + } + if (err.code === -32002) { + setError('A connection request is already pending. Check MetaMask.'); + return; + } + setError(err.message ?? 'Connection failed'); + } finally { + setConnecting(false); + } + }, []); + + const handleDisconnect = useCallback(async () => { + const client = clientRef.current; + if (!client) return; + await client.disconnect(); + setAccounts([]); + setChainId(null); + setBalance(''); + }, []); + + const fetchBalance = useCallback(async () => { + const client = clientRef.current; + if (!client || accounts.length === 0) return; + + const provider = client.getProvider(); + const wei = (await provider.request({ + method: 'eth_getBalance', + params: [accounts[0], 'latest'], + })) as Hex; + + const ethBalance = parseInt(wei, 16) / 1e18; + setBalance(ethBalance.toFixed(6)); + }, [accounts]); + + // chainConfiguration must match the target chain (and include its chainId) — + // the wallet receives it verbatim as wallet_addEthereumChain params + const handleSwitchToPolygon = useCallback(async () => { + const client = clientRef.current; + if (!client) return; + + try { + await client.switchChain({ + chainId: '0x89', + chainConfiguration: { + chainId: '0x89', + chainName: 'Polygon', + nativeCurrency: { name: 'MATIC', symbol: 'MATIC', decimals: 18 }, + rpcUrls: ['https://polygon-rpc.com'], + blockExplorerUrls: ['https://polygonscan.com'], + }, + }); + } catch (err: any) { + if (err.code === 4001) { + setError('Chain switch rejected by user.'); + } + } + }, []); + + const isConnected = accounts.length > 0; + + if (!isConnected) { + return ( +
+ + {error &&

{error}

} +
+ ); + } + + return ( +
+

Account: {accounts[0]}

+

Chain ID: {chainId}

+

Balance: {balance || '—'} ETH

+ + + + {error &&

{error}

} +
+ ); +} +``` + +### Step 4: Use provider.request for RPC calls + +Once connected, use the EIP-1193 provider for any Ethereum JSON-RPC method: + +```typescript +const provider = client.getProvider(); + +// Get current block number +const blockNumber = await provider.request({ method: 'eth_blockNumber' }); + +// Get chain ID +const chainId = await provider.request({ method: 'eth_chainId' }); + +// Get accounts +const accounts = await provider.request({ method: 'eth_accounts' }); + +// Get balance +const balance = await provider.request({ + method: 'eth_getBalance', + params: [accounts[0], 'latest'], +}); + +// Get transaction count (nonce) +const nonce = await provider.request({ + method: 'eth_getTransactionCount', + params: [accounts[0], 'latest'], +}); +``` + +### Step 5: Switch chains + +Use `client.switchChain` to request a network change. The `chainConfiguration` fallback triggers `wallet_addEthereumChain` if the chain is not already in the user's wallet: + +```typescript +await client.switchChain({ + chainId: '0xaa36a7', // Sepolia +}); + +// With fallback configuration for unknown chains +await client.switchChain({ + chainId: '0xa4b1', // Arbitrum One + chainConfiguration: { + chainId: '0xa4b1', // optional in the type, but set it to the target chain — if omitted it falls back to the currently selected chain (likely the wrong chain to add) + chainName: 'Arbitrum One', + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + rpcUrls: ['https://arb1.arbitrum.io/rpc'], + blockExplorerUrls: ['https://arbiscan.io'], + }, +}); +``` + +### Step 6: Handle errors + +Always catch and handle known error codes: + +```typescript +try { + await client.connect({ chainIds: ['0x1'] }); +} catch (err: any) { + switch (err.code) { + case 4001: + // User rejected the request — show retry UI + break; + case -32002: + // Request already pending — tell user to check MetaMask + break; + default: + console.error('Unexpected error:', err); + } +} +``` + +## Important Notes + +- **Call `createEVMClient` once at app startup** — store the promise and reuse it; never call it per render. Each call returns a _new_ EVM client wrapper, but they all share one underlying multichain core (the core is the singleton, and its options are merged across calls). +- **Chain IDs are always hex strings** — use `'0x1'` (Ethereum), `'0x89'` (Polygon), `'0xaa36a7'` (Sepolia). Never use decimal numbers. +- **`0x1` (Ethereum mainnet) is always auto-included** in `connect()` regardless of the `chainIds` you pass. +- **The provider exists before connection** — `client.getProvider()` never returns `undefined`. But node-routed reads (`eth_blockNumber`, `eth_getBalance`, `eth_call`, …) require a **selected chain** and throw `No chain ID selected` until one is set (after `connect()` or a restored session). Only the intercepted methods `eth_chainId` and `eth_accounts` (served from cached state) are safe before connecting. +- **Register event listeners early** — set up `accountsChanged`, `chainChanged`, and `disconnect` listeners in `useEffect` before the user connects. +- **Error code 4001 is not an application error** — it means the user deliberately rejected. Handle it gracefully with a retry option. +- **Error code -32002 means a request is pending** — do not fire another `connect()` call. Wait for the user to act in MetaMask. diff --git a/skills/metamask-connect/workflows/setup-multichain.md b/skills/metamask-connect/workflows/setup-multichain.md new file mode 100644 index 00000000..acbe5442 --- /dev/null +++ b/skills/metamask-connect/workflows/setup-multichain.md @@ -0,0 +1,301 @@ +# Setup Multichain App with MetaMask + +## When to use + +Use this skill when: + +- Building an app that needs both EVM and Solana wallet connectivity through a single MetaMask session +- Using `createMultichainClient` from `@metamask/connect-multichain` +- Calling `invokeMethod` with CAIP-2 scopes for cross-chain RPC or signing +- Handling `wallet_sessionChanged` events for multichain session state +- Running in headless mode with custom QR rendering via `display_uri` +- Configuring `getInfuraRpcUrls` for EVM and Solana RPC transport + +## Workflow + +### Step 1: Install dependencies + +```bash +npm install @metamask/connect-multichain +``` + +For Solana transaction building (optional): + +```bash +npm install @solana/web3.js +``` + +### Step 2: Create the multichain client + +`createMultichainClient` is a **singleton** — calling it multiple times returns the same instance with merged options. Never recreate it per render. + +```typescript +import { + createMultichainClient, + getInfuraRpcUrls, +} from '@metamask/connect-multichain'; + +const client = await createMultichainClient({ + dapp: { + name: 'My Multichain DApp', + url: window.location.href, + iconUrl: 'https://mydapp.com/icon.png', // optional (or use base64Icon for embedded icons) + }, + api: { + supportedNetworks: { + ...getInfuraRpcUrls({ + infuraApiKey: 'YOUR_INFURA_API_KEY', + caipChainIds: [ + 'eip155:1', + 'eip155:137', + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + ], + }), + }, + }, + ui: { + headless: false, // set true for custom QR rendering + }, +}); +``` + +**`getInfuraRpcUrls({ infuraApiKey, caipChainIds? })`** returns a CAIP-2 keyed map of Infura RPC URLs for supported networks (EVM chains and Solana). Pass `caipChainIds` to limit the output to specific chains. Merge with any custom network RPCs. + +**Singleton behavior:** The `dapp` object from the first call is used for the lifetime of the client — it is ignored on subsequent calls (not merged). Call `createMultichainClient` once at app startup. + +### Step 3: Connect with mixed EVM + Solana scopes + +Scopes use CAIP-2 format: `'eip155:N'` for EVM chains, `'solana:'` for Solana. + +```typescript +// connect() resolves with no value (Promise) +await client.connect( + [ + 'eip155:1', // Ethereum mainnet + 'eip155:137', // Polygon + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', // Solana mainnet + ], + [], // caipAccountIds (empty for initial connection) +); + +// Session data arrives via the wallet_sessionChanged event (see Step 6), +// or read it on demand: +const session = await client.provider.getSession(); +console.log(session?.sessionScopes); // approved scopes with their accounts +``` + +**Solana CAIP-2 identifiers:** + +| Network | CAIP-2 ID | +| ------- | ----------------------------------------- | +| Mainnet | `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp` | +| Devnet | `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1` | +| Testnet | `solana:4uhcVJyU9pJkvQyS88uRDiswHXSCkY3z` | + +All three Solana scopes are modeled by the SDK; non-mainnet availability depends on the connected MetaMask build/version, so don't assume a cluster is present — handle connection errors. + +You can optionally pass `caipAccountIds` (second argument) to hint at specific accounts: + +```typescript +await client.connect(['eip155:1'], ['eip155:1:0xYourAddress']); +``` + +### Step 4: Invoke EVM methods + +Use `invokeMethod` with a CAIP-2 scope and a JSON-RPC request object. + +**EVM read methods** (eth_call, eth_getBalance, eth_blockNumber, etc.) route through the **RPC node**. **Signing methods** (eth_sendTransaction, personal_sign, etc.) route through the **wallet**. + +```typescript +// Read: eth_getBalance via RPC node +const balance = await client.invokeMethod({ + scope: 'eip155:1', + request: { + method: 'eth_getBalance', + params: ['0xYourAddress', 'latest'], + }, +}); + +// Sign: personal_sign via wallet +const signature = await client.invokeMethod({ + scope: 'eip155:1', + request: { + method: 'personal_sign', + params: ['0x48656c6c6f', '0xYourAddress'], + }, +}); + +// Send transaction via wallet +const txHash = await client.invokeMethod({ + scope: 'eip155:137', + request: { + method: 'eth_sendTransaction', + params: [ + { + from: '0xYourAddress', + to: '0xRecipient', + value: '0x2386F26FC10000', // 0.01 ETH in wei (hex) + gas: '0x5208', + }, + ], + }, +}); +``` + +### Step 5: Invoke Solana methods + +**All Solana methods route through the wallet** — only EVM read methods are routed to RPC nodes. (The Solana entries in `supportedNetworks` declare which networks the dapp uses; they are not used to route Solana requests.) + +Method names have **no `solana_` prefix**, and params take an `account: { address }` object: + +```typescript +const SOLANA_MAINNET = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'; + +// Sign a message +const signResult = await client.invokeMethod({ + scope: SOLANA_MAINNET, + request: { + method: 'signMessage', + params: { + account: { address: 'YourSolanaAddress' }, + message: btoa('Hello from Solana!'), // base64-encoded message bytes + }, + }, +}); +// signResult: { signature: , signedMessage: , signatureType: 'ed25519' } + +// Sign and send a transaction +const txResult = await client.invokeMethod({ + scope: SOLANA_MAINNET, + request: { + method: 'signAndSendTransaction', + params: { + account: { address: 'YourSolanaAddress' }, + transaction: base64EncodedTransaction, // base64-encoded serialized transaction + scope: SOLANA_MAINNET, + }, + }, +}); +// txResult: { signature: } +``` + +To sign without sending, use `signTransaction` (same params) — it returns `{ signedTransaction: }`. + +### Step 6: Listen for session events + +Register event listeners **before** calling `connect()`. + +```typescript +// Session state changes (accounts added/removed, scopes changed) +// Payload is SessionData | undefined — scopes live under sessionScopes +client.on('wallet_sessionChanged', (session) => { + const scopes = session?.sessionScopes ?? {}; + console.log('Approved scopes:', Object.keys(scopes)); + // Accounts are CAIP-10 strings, e.g. 'eip155:1:0xabc...' — take the last segment for the address +}); + +// Connection status changes +client.on('stateChanged', (status) => { + // status: 'loaded' | 'pending' | 'connecting' | 'connected' | 'disconnected' + console.log('Connection status:', status); +}); + +// display_uri fires during 'connecting' state — headless QR code flow +client.on('display_uri', (uri: string) => { + renderQrCode(uri); +}); +``` + +**`display_uri` timing:** The event only fires during the connecting phase. Register the listener before `connect()`. In headless mode, if an error occurs during connection, do not attempt to regenerate the QR — start a new `connect()` call instead. + +### Step 7: Headless mode + +For full control over the connection UI: + +```typescript +const client = await createMultichainClient({ + dapp: { name: 'My DApp', url: window.location.href }, + api: { + supportedNetworks: getInfuraRpcUrls({ + infuraApiKey: 'YOUR_INFURA_API_KEY', + }), + }, + ui: { headless: true }, +}); + +client.on('display_uri', (uri: string) => { + // Render your own QR code or deeplink UI + showCustomQrModal(uri); +}); + +await client.connect( + ['eip155:1', 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'], + [], +); + +// Hide QR modal on successful connection +hideCustomQrModal(); +``` + +### Step 8: Selective disconnect + +```typescript +// Disconnect only Solana scope — EVM session stays active +await client.disconnect(['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp']); + +// Full disconnect — revoke all scopes, terminate session +await client.disconnect(); +``` + +### Step 9: Error handling + +```typescript +import { RPCInvokeMethodErr } from '@metamask/connect-multichain'; + +try { + await client.connect(['eip155:1'], []); +} catch (err: any) { + if (err?.message?.includes('Existing connection is pending')) { + // MWP: a previous connect() is still pending — do NOT retry. + // Show "Check your MetaMask Mobile app to continue" message. + // (This error has no numeric code.) + } else if ( + err?.code === 4001 || + /reject|denied|cancel/i.test(err?.message ?? '') + ) { + // User rejected the connection — show retry UI + } else { + console.error('Connection error:', err); + } +} + +// invokeMethod errors are wrapped in RPCInvokeMethodErr (err.code === 53). +// The wallet's original EIP-1193 / JSON-RPC code is on err.rpcCode +// (with err.rpcMessage and err.rpcData for revert data). +try { + await client.invokeMethod({ + scope: 'eip155:1', + request: { + method: 'personal_sign', + params: ['0x48656c6c6f', '0xYourAddress'], + }, + }); +} catch (err) { + if (err instanceof RPCInvokeMethodErr && err.rpcCode === 4001) { + // User rejected the signature — not an app error + } else { + throw err; + } +} +``` + +## Important Notes + +- **Singleton:** `createMultichainClient` is a singleton. The `dapp` object from the first call is used for the client's lifetime (later calls' `dapp` is ignored). Call it once at app startup and reuse the returned client. +- **Concurrent connect throws on MWP:** Never call `connect()` while a previous `connect()` is still pending — it throws a plain `Error` ("Existing connection is pending...") with **no numeric code**. (`-32002` only comes from the extension transport's own RPC queue.) +- **EVM read vs sign routing:** EVM read methods (eth_call, eth_getBalance, etc.) go to the RPC node configured in `supportedNetworks`. Signing methods go to the wallet. All Solana methods always go to the wallet. +- **Scope format:** EVM scopes are `'eip155:'` (e.g., `'eip155:1'`). Solana scopes use the genesis hash: `'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'`. +- **`display_uri`:** Only fires during the connecting phase. Register before `connect()`. Do not regenerate QR on connection error — start a fresh `connect()`. +- **Selective disconnect:** Passing specific scopes only revokes those scopes. Omit arguments to fully terminate the session. +- **Node.js / React Native:** `dapp.url` is **required** in non-browser environments (there is no `window.location`). +- **Solana networks:** mainnet, devnet, and testnet scopes are all modeled by the SDK; non-mainnet availability depends on the connected MetaMask build/version, so handle connection errors rather than assuming a cluster is present. diff --git a/skills/metamask-connect/workflows/setup-solana-browser.md b/skills/metamask-connect/workflows/setup-solana-browser.md new file mode 100644 index 00000000..10f2c4cd --- /dev/null +++ b/skills/metamask-connect/workflows/setup-solana-browser.md @@ -0,0 +1,267 @@ +# Setup Solana Browser App with MetaMask + +## When to use + +Use this skill when: + +- Integrating MetaMask with Solana in a vanilla JavaScript or non-React browser app +- Using wallet-standard features directly without `@solana/wallet-adapter-react` +- Building connect, sign, and send flows with the `SolanaClient` API +- Accessing wallet-standard features like `solana:signTransaction` or `solana:signMessage` directly + +## Workflow + +### Step 1: Install dependencies + +```bash +npm install @metamask/connect-solana @metamask/connect-multichain @solana/web3.js +``` + +`@metamask/connect-multichain` is a regular dependency of `@metamask/connect-solana` and is installed transitively. (Only the 2.0.0 release briefly made it a peer dependency; 2.1.0 reverted that.) Installing it explicitly is harmless but not required. The SDK warns at runtime if duplicate or mismatched copies are resolved. + +### Step 2: Create the Solana client + +```typescript +import { createSolanaClient } from '@metamask/connect-solana'; + +const solanaClient = await createSolanaClient({ + dapp: { + name: 'My Solana DApp', + url: window.location.href, + }, + api: { + supportedNetworks: { + mainnet: 'https://api.mainnet-beta.solana.com', + }, + }, +}); +``` + +**`createSolanaClient` returns `Promise`:** + +| Property | Type | Description | +| ------------------ | --------------------- | -------------------------------------------------------------------------- | +| `core` | `MultichainCore` | The underlying multichain client instance | +| `getWallet()` | `() => Wallet` | Returns the wallet-standard `Wallet` (from `@wallet-standard/base`) | +| `registerWallet()` | `() => Promise` | Manually register the wallet (auto-called unless `skipAutoRegister: true`) | +| `disconnect()` | `() => Promise` | Disconnect and revoke Solana scopes | + +### Step 3: Get the wallet and connect + +```typescript +const wallet = solanaClient.getWallet(); + +// The wallet exposes wallet-standard features +console.log('Wallet name:', wallet.name); // "MetaMask" +console.log('Available features:', Object.keys(wallet.features)); +``` + +Connect using the `standard:connect` feature: + +```typescript +const connectFeature = wallet.features['standard:connect']; +const { accounts } = await connectFeature.connect(); + +if (accounts.length > 0) { + const account = accounts[0]; + console.log('Address:', account.address); + console.log('Public key:', account.publicKey); // Uint8Array + console.log('Chains:', account.chains); // e.g. ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'] +} +``` + +### Step 4: Access wallet-standard features + +The wallet exposes these wallet-standard features: + +| Feature Key | Description | +| ------------------------------- | ----------------------------------------- | +| `standard:connect` | Connect and request accounts | +| `standard:disconnect` | Disconnect the wallet | +| `standard:events` | Subscribe to account/chain change events | +| `solana:signIn` | Sign-In-With-Solana (SIWS) authentication | +| `solana:signTransaction` | Sign a transaction without sending | +| `solana:signAndSendTransaction` | Sign and broadcast a transaction | +| `solana:signMessage` | Sign an arbitrary message | + +There is **no** `solana:signAndSendAllTransactions` feature — to batch, pass multiple inputs to `signAndSendTransaction(...inputs)` (it is variadic and returns one result per input). + +### Step 5: Sign a message + +```typescript +const signMessageFeature = wallet.features['solana:signMessage']; + +const message = new TextEncoder().encode('Hello from MetaMask on Solana!'); + +const [{ signature }] = await signMessageFeature.signMessage({ + account: accounts[0], + message, +}); + +console.log('Signature:', Buffer.from(signature).toString('hex')); +``` + +### Step 6: Sign and send a transaction + +```typescript +import { + Connection, + Transaction, + SystemProgram, + PublicKey, + LAMPORTS_PER_SOL, +} from '@solana/web3.js'; + +const connection = new Connection('https://api.mainnet-beta.solana.com'); +const { blockhash } = await connection.getLatestBlockhash(); + +const senderPubkey = new PublicKey(accounts[0].address); + +const transaction = new Transaction().add( + SystemProgram.transfer({ + fromPubkey: senderPubkey, + toPubkey: new PublicKey('11111111111111111111111111111112'), + lamports: 0.001 * LAMPORTS_PER_SOL, + }), +); +transaction.recentBlockhash = blockhash; +transaction.feePayer = senderPubkey; + +const serializedTransaction = transaction.serialize({ + requireAllSignatures: false, +}); + +const signAndSendFeature = wallet.features['solana:signAndSendTransaction']; + +const [{ signature: txSignature }] = + await signAndSendFeature.signAndSendTransaction({ + account: accounts[0], + transaction: serializedTransaction, + chain: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + }); + +// txSignature is a Uint8Array — encode with bs58 ('base58' is NOT a Buffer encoding). +// Requires `npm install bs58` and `import bs58 from 'bs58'` at the top of the file. +const signatureBase58 = bs58.encode(txSignature); +console.log('Transaction signature:', signatureBase58); + +// confirmTransaction expects the base58 signature string +const confirmation = await connection.confirmTransaction( + signatureBase58, + 'confirmed', +); +console.log('Confirmed:', confirmation); +``` + +### Step 7: Listen for account and chain changes + +```typescript +const eventsFeature = wallet.features['standard:events']; + +eventsFeature.on('change', ({ accounts: newAccounts }) => { + if (newAccounts) { + console.log( + 'Accounts changed:', + newAccounts.map((a) => a.address), + ); + } +}); +``` + +### Step 8: Disconnect + +```typescript +await solanaClient.disconnect(); +``` + +### Step 9: Full working example + +```typescript +import { createSolanaClient } from '@metamask/connect-solana'; +import { + Connection, + Transaction, + SystemProgram, + PublicKey, + LAMPORTS_PER_SOL, +} from '@solana/web3.js'; + +async function main() { + const solanaClient = await createSolanaClient({ + dapp: { + name: 'My Solana DApp', + url: window.location.href, + }, + api: { + supportedNetworks: { + mainnet: 'https://api.mainnet-beta.solana.com', + }, + }, + }); + + const wallet = solanaClient.getWallet(); + + // Connect + const connectFeature = wallet.features['standard:connect']; + const { accounts } = await connectFeature.connect(); + const account = accounts[0]; + console.log('Connected:', account.address); + + // Sign message + const signMessageFeature = wallet.features['solana:signMessage']; + const [{ signature }] = await signMessageFeature.signMessage({ + account, + message: new TextEncoder().encode('Hello Solana!'), + }); + console.log('Message signed'); + + // Send transaction + const connection = new Connection('https://api.mainnet-beta.solana.com'); + const { blockhash } = await connection.getLatestBlockhash(); + const senderPubkey = new PublicKey(account.address); + + const tx = new Transaction().add( + SystemProgram.transfer({ + fromPubkey: senderPubkey, + toPubkey: new PublicKey('11111111111111111111111111111112'), + lamports: 0.001 * LAMPORTS_PER_SOL, + }), + ); + tx.recentBlockhash = blockhash; + tx.feePayer = senderPubkey; + + const signAndSendFeature = wallet.features['solana:signAndSendTransaction']; + const [{ signature: txSig }] = + await signAndSendFeature.signAndSendTransaction({ + account, + transaction: tx.serialize({ requireAllSignatures: false }), + chain: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + }); + console.log('Transaction sent'); + + // Disconnect + await solanaClient.disconnect(); +} + +main().catch(console.error); +``` + +## Important Notes + +- **`createSolanaClient` is async** — always `await` it before accessing the wallet. The factory returns a `Promise`. +- **`getInfuraRpcUrls` helper** — use `getInfuraRpcUrls({ infuraApiKey: 'YOUR_KEY', networks: ['mainnet', 'devnet'] })` from `@metamask/connect-solana` to auto-generate `supportedNetworks` from Infura. Returns `SolanaSupportedNetworks`. +- **Wallet name is exactly `"MetaMask"`** — case-sensitive. Use this to identify the wallet if you enumerate registered wallets. +- **Feature keys are string constants** — always access features via bracket notation (e.g., `wallet.features['solana:signTransaction']`), not dot notation. +- **Solana networks** — mainnet, devnet, and testnet scopes are all modeled by the SDK and wallet-standard layer. Non-mainnet availability depends on the connected MetaMask build/version, so handle connection errors rather than assuming a cluster is present; for reads, point a `@solana/web3.js` `Connection` at the matching cluster. +- **`skipAutoRegister` option** — pass `skipAutoRegister: true` to `createSolanaClient` to prevent automatic wallet-standard registration. Useful when you want to control when the wallet becomes discoverable. +- **`analytics.integrationType` option** — pass an `analytics: { integrationType: 'your-integration' }` string to `createSolanaClient` (added in `@metamask/connect-solana` 0.8.0) to tag analytics events with your integration identifier. +- **Injected Solana provider wins** — since `@metamask/connect-solana` 1.0.0, if an injected Solana provider is already present (e.g. the MetaMask browser extension), `createSolanaClient` will not announce its own wallet-standard provider. Don't expect two `"MetaMask"` entries in the registered wallets list. +- **Eager provider initialization + stable `getWallet()`** — since `@metamask/connect-solana` 1.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 (no need to wait for `wallet_sessionChanged` on cold start). `getWallet()` also returns the same wallet instance on every call now — safe to cache in a module-level constant, no need to re-await or recreate on subsequent access. +- **`disconnect()` only revokes Solana scopes** — EVM sessions managed by other clients remain active. +- **Chrome on Android** has a known bug where the page may unload during the connection flow. Add a `beforeunload` listener as a workaround: + ```typescript + window.addEventListener('beforeunload', (e) => { + e.preventDefault(); + e.returnValue = ''; + }); + ``` diff --git a/skills/metamask-connect/workflows/setup-solana-react-native.md b/skills/metamask-connect/workflows/setup-solana-react-native.md new file mode 100644 index 00000000..6e8b2885 --- /dev/null +++ b/skills/metamask-connect/workflows/setup-solana-react-native.md @@ -0,0 +1,399 @@ +# Setup Solana React Native App with MetaMask + +## When to use + +Use this skill when: + +- Integrating MetaMask with Solana in a React Native application +- Setting up the required polyfills and metro shims for `@metamask/connect-solana` +- Building Solana sign and send flows in React Native using `invokeMethod` +- You need Solana support in React Native where `@solana/wallet-adapter-react` is **not** available + +## Workflow + +### Step 1: Install dependencies + +```bash +npm install @metamask/connect-solana @metamask/connect-multichain @solana/web3.js react-native-get-random-values buffer readable-stream @react-native-async-storage/async-storage +``` + +`@metamask/connect-multichain` is a regular dependency of `@metamask/connect-solana` and is installed transitively — but this skill imports `createMultichainClient` directly (to configure `mobile.preferredOpenLink`, which `createSolanaClient` does not forward), so declare it explicitly to keep strict package managers (pnpm) happy. The SDK warns at runtime if duplicate or mismatched copies are resolved. + +### Step 2: Create the polyfills file + +Create `polyfills.ts` at the root of your project. + +```typescript +// polyfills.ts +import { Buffer } from 'buffer'; + +// Buffer — connect-multichain self-polyfills this, but set early as a safety +// net for peer deps (e.g. @solana/web3.js) that may load first. +global.Buffer = Buffer; + +// window object — required for correct platform detection and deeplink behaviour. +const eventListeners = new Map>(); +if (typeof global.window === 'undefined') { + (global as any).window = { + location: { + hostname: 'my-rn-app', + href: 'https://my-rn-app.local', + }, + navigator: { product: 'ReactNative' }, + addEventListener: (event: string, listener: EventListener) => { + if (!eventListeners.has(event)) eventListeners.set(event, new Set()); + eventListeners.get(event)?.add(listener); + }, + removeEventListener: (event: string, listener: EventListener) => { + eventListeners.get(event)?.delete(listener); + }, + dispatchEvent: (_event: Event) => true, + }; +} + +// NOTE: Event and CustomEvent are NOT needed for standalone connect-solana — +// the SDK uses eventemitter3 internally. Add them only if using wagmi. +``` + +### Step 3: Import polyfills FIRST in the entry file + +`react-native-get-random-values` must be the **very first import** in the entry file — it cannot be inside `polyfills.ts` because Metro may have already touched crypto by the time that file runs. + +```typescript +// index.js or App.tsx — import order is critical +import 'react-native-get-random-values'; // MUST be first (needed for RN < 0.72; safe to include on 0.72+) +import './polyfills'; + +import { AppRegistry } from 'react-native'; +import App from './App'; +import { name as appName } from './app.json'; + +AppRegistry.registerComponent(appName, () => App); +``` + +### Step 4: Configure metro shims + +Add `extraNodeModules` to `metro.config.js` so the bundler can resolve Node.js built-in modules: + +```javascript +const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config'); +const path = require('path'); + +// src/empty-module.js: `module.exports = {};` +// Only `stream` needs a real shim — the other Node built-ins are referenced +// by transitive deps but never called at runtime in React Native. +const emptyModule = path.resolve(__dirname, 'src', 'empty-module.js'); + +const config = { + 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, + }, + }, +}; + +module.exports = mergeConfig(getDefaultConfig(__dirname), config); +``` + +### Step 5: Create the Solana client + +`createSolanaClient` does not forward `mobile` options to the underlying multichain core. In React Native, you must call `createMultichainClient` first with `mobile.preferredOpenLink` so the singleton core is configured for deeplinks, then call `createSolanaClient` which reuses that same core. + +```typescript +import { createMultichainClient } from '@metamask/connect-multichain'; +import { createSolanaClient } from '@metamask/connect-solana'; +import { Linking } from 'react-native'; + +// Initialize the multichain singleton with mobile deeplink handling +await createMultichainClient({ + dapp: { + name: 'My Solana RN App', + url: 'https://myapp.com', + }, + api: { + supportedNetworks: {}, + }, + mobile: { + preferredOpenLink: (deeplink: string) => Linking.openURL(deeplink), + }, +}); + +// Create the Solana client — reuses the multichain singleton above +const solanaClient = await createSolanaClient({ + dapp: { + name: 'My Solana RN App', + url: 'https://myapp.com', + }, + api: { + supportedNetworks: { + mainnet: 'https://api.mainnet-beta.solana.com', + }, + }, +}); +``` + +### Step 6: Use multichain invokeMethod for Solana operations + +**There is no `@solana/wallet-adapter-react` in React Native.** Instead, use the `core` multichain client and `invokeMethod` to call Solana RPC methods on specific CAIP scopes. + +#### Connect + +```typescript +await solanaClient.core.connect( + ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'], + [], +); +``` + +Listen for `wallet_sessionChanged` to get accounts after connection: + +```typescript +solanaClient.core.on('wallet_sessionChanged', (session) => { + // Scopes live under session.sessionScopes; accounts are CAIP-10 strings + // ('solana::
') — take the last segment for the address + const caipAccounts = + session?.sessionScopes?.['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'] + ?.accounts ?? []; + const solanaAccounts = caipAccounts.map((a) => a.split(':')[2]); + console.log('Solana accounts:', solanaAccounts); +}); +``` + +#### Sign a message + +```typescript +const message = new TextEncoder().encode('Hello from React Native!'); +const messageBase64 = Buffer.from(message).toString('base64'); + +// Method names have no `solana_` prefix; the account is passed as account: { address } +const result = await solanaClient.core.invokeMethod({ + scope: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + request: { + method: 'signMessage', + params: { + account: { address: solanaAccounts[0] }, + message: messageBase64, + }, + }, +}); + +// result: { signature: , signedMessage: , signatureType: 'ed25519' } +console.log('Signature:', result.signature); +``` + +#### Sign and send a transaction + +```typescript +import { + Connection, + Transaction, + SystemProgram, + PublicKey, + LAMPORTS_PER_SOL, +} from '@solana/web3.js'; + +const connection = new Connection('https://api.mainnet-beta.solana.com'); +const { blockhash } = await connection.getLatestBlockhash(); +const senderPubkey = new PublicKey(solanaAccounts[0]); + +const tx = new Transaction().add( + SystemProgram.transfer({ + fromPubkey: senderPubkey, + toPubkey: new PublicKey('11111111111111111111111111111112'), + lamports: 0.001 * LAMPORTS_PER_SOL, + }), +); +tx.recentBlockhash = blockhash; +tx.feePayer = senderPubkey; + +const serializedTx = tx.serialize({ requireAllSignatures: false }); +const txBase64 = Buffer.from(serializedTx).toString('base64'); + +const SOLANA_MAINNET = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'; +const sendResult = await solanaClient.core.invokeMethod({ + scope: SOLANA_MAINNET, + request: { + method: 'signAndSendTransaction', + params: { + account: { address: solanaAccounts[0] }, + transaction: txBase64, + scope: SOLANA_MAINNET, + }, + }, +}); + +// sendResult: { signature: } +console.log('Transaction signature:', sendResult.signature); +``` + +#### Disconnect + +```typescript +await solanaClient.disconnect(); +``` + +### Step 7: Full React Native component + +```tsx +import React, { useState, useEffect } from 'react'; +import { View, Text, Button, Alert, Linking } from 'react-native'; +import { createMultichainClient } from '@metamask/connect-multichain'; +import { createSolanaClient, SolanaClient } from '@metamask/connect-solana'; +import { + Connection, + Transaction, + SystemProgram, + PublicKey, + LAMPORTS_PER_SOL, +} from '@solana/web3.js'; + +const MAINNET_SCOPE = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'; +const RPC_URL = 'https://api.mainnet-beta.solana.com'; + +export default function SolanaScreen() { + const [client, setClient] = useState(null); + const [accounts, setAccounts] = useState([]); + + useEffect(() => { + (async () => { + await createMultichainClient({ + dapp: { name: 'My RN App', url: 'https://myapp.com' }, + api: { supportedNetworks: {} }, + mobile: { preferredOpenLink: (dl) => Linking.openURL(dl) }, + }); + const c = await createSolanaClient({ + dapp: { name: 'My RN App', url: 'https://myapp.com' }, + api: { supportedNetworks: { mainnet: RPC_URL } }, + }); + setClient(c); + c.core.on('wallet_sessionChanged', (session) => { + const caipAccounts = + session?.sessionScopes?.[MAINNET_SCOPE]?.accounts ?? []; + setAccounts(caipAccounts.map((a) => a.split(':')[2])); + }); + })(); + }, []); + + const handleConnect = async () => { + if (!client) return; + try { + await client.core.connect([MAINNET_SCOPE], []); + } catch (err: any) { + Alert.alert('Connection failed', err.message); + } + }; + + const handleSignMessage = async () => { + if (!client || accounts.length === 0) return; + try { + const message = Buffer.from('Hello from React Native!').toString( + 'base64', + ); + const result = await client.core.invokeMethod({ + scope: MAINNET_SCOPE, + request: { + method: 'signMessage', + params: { account: { address: accounts[0] }, message }, + }, + }); + Alert.alert( + 'Signed', + JSON.stringify(result.signature).slice(0, 40) + '...', + ); + } catch (err: any) { + Alert.alert('Sign failed', err.message); + } + }; + + const handleSendTransaction = async () => { + if (!client || accounts.length === 0) return; + try { + const connection = new Connection(RPC_URL); + const { blockhash } = await connection.getLatestBlockhash(); + const sender = new PublicKey(accounts[0]); + + const tx = new Transaction().add( + SystemProgram.transfer({ + fromPubkey: sender, + toPubkey: new PublicKey('11111111111111111111111111111112'), + lamports: 0.001 * LAMPORTS_PER_SOL, + }), + ); + tx.recentBlockhash = blockhash; + tx.feePayer = sender; + + const txBase64 = Buffer.from( + tx.serialize({ requireAllSignatures: false }), + ).toString('base64'); + + const result = await client.core.invokeMethod({ + scope: MAINNET_SCOPE, + request: { + method: 'signAndSendTransaction', + params: { + account: { address: accounts[0] }, + transaction: txBase64, + scope: MAINNET_SCOPE, + }, + }, + }); + Alert.alert('Sent', result.signature); + } catch (err: any) { + Alert.alert('Transaction failed', err.message); + } + }; + + const handleDisconnect = async () => { + if (!client) return; + await client.disconnect(); + setAccounts([]); + }; + + if (accounts.length === 0) { + return ( + + + + + )} + + ); +} +``` + +### Step 6: Chrome on Android workaround + +Chrome on Android has a known bug where the page may unload before MetaMask can respond. Add a `beforeunload` patch in your entry file: + +```typescript +window.addEventListener('beforeunload', (e) => { + e.preventDefault(); + e.returnValue = ''; +}); +``` + +## Important Notes + +- **Initialize `createSolanaClient` early, but rendering need not wait** — wallet registration happens shortly _after_ the factory resolves (the SDK defers it ~1s), and the wallet adapter discovers late registrations via the wallet-standard register event. Only gate UI that assumes MetaMask is already in the wallet list. +- **`wallets` prop must be `[]`** — MetaMask uses wallet-standard auto-discovery. Passing wallet adapter instances manually will not work and may cause duplicates with other wallets. +- **The wallet name is exactly `"MetaMask"`** — case-sensitive. Use this to identify the MetaMask wallet in the adapter list. Renamed from `"MetaMask Connect"` in `@metamask/connect-solana` 1.0.0; since that release, the client will also defer to an already-injected Solana provider (e.g. the MetaMask browser extension) instead of announcing a second `"MetaMask"` entry. +- **Eager provider initialization + stable `getWallet()`** — since `@metamask/connect-solana` 1.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 (no need to wait for `wallet_sessionChanged` on cold start). `getWallet()` also returns the same wallet instance on every call now — safe to cache in a `useRef` / `useMemo` value, no need to call it on every render. +- **`getInfuraRpcUrls` helper** — use `getInfuraRpcUrls({ infuraApiKey: 'YOUR_KEY', networks: ['mainnet', 'devnet'] })` from `@metamask/connect-solana` to auto-generate `supportedNetworks` from Infura. +- **Solana networks** — mainnet, devnet, and testnet scopes are all modeled by the SDK and wallet-standard layer. Non-mainnet availability depends on the connected MetaMask build/version, so handle connection errors rather than assuming a cluster is present. +- **`disconnect()` only revokes Solana scopes** — if the user also has EVM sessions, those remain active. Each chain family manages its own session lifecycle. +- **Chrome on Android** — a known browser bug can interrupt the connection flow. Apply the `beforeunload` workaround shown in Step 6. diff --git a/skills/metamask-connect/workflows/setup-wagmi-connector.md b/skills/metamask-connect/workflows/setup-wagmi-connector.md new file mode 100644 index 00000000..f4a29ff4 --- /dev/null +++ b/skills/metamask-connect/workflows/setup-wagmi-connector.md @@ -0,0 +1,303 @@ +# Set Up Wagmi with MetaMask Connect EVM Connector + +## When to use + +- Building a new wagmi-based dApp that needs MetaMask wallet connectivity +- Adding the MetaMask connector to an existing wagmi config +- Need a working wagmi + MetaMask setup with connection, chain switching, signing, and transactions +- Integrating MetaMask via the new `@metamask/connect-evm` SDK in a wagmi project + +## Workflow + +### Step 1: Install Dependencies + +```bash +npm install wagmi viem @tanstack/react-query + +# Check which @metamask/connect-evm range wagmi's connector was built against: +npm info @wagmi/connectors peerDependencies +# ... then install a version inside that range (currently ^1.3.0): +npm install @metamask/connect-evm@"^1.3.0" +``` + +The connect-evm-backed `metaMask()` connector ships in **wagmi >= 3.6 / `@wagmi/connectors` >= 8**, which declares `@metamask/connect-evm` as an **optional peer dependency**. Install a version that satisfies wagmi's declared peer range — do **not** install `@metamask/connect-evm@latest` blindly: the current 2.x line does not satisfy `^1.3.0`, and pairing the connector with a major it wasn't built against produces peer warnings and undefined behavior. `@metamask/connect-multichain` is installed transitively by `connect-evm`; you do not need to add it. + +### Step 2: Create Wagmi Config + +```typescript +import { createConfig, http } from 'wagmi'; +import { mainnet, sepolia, optimism, polygon } from 'wagmi/chains'; +import { metaMask } from 'wagmi/connectors'; + +export const config = createConfig({ + chains: [mainnet, sepolia, optimism, polygon], + connectors: [ + metaMask({ + dapp: { + name: 'My Dapp', + url: window.location.href, + iconUrl: 'https://mydapp.com/icon.png', + }, + debug: false, + }), + ], + transports: { + [mainnet.id]: http(), + [sepolia.id]: http(), + [optimism.id]: http(), + [polygon.id]: http(), + }, +}); + +declare module 'wagmi' { + interface Register { + config: typeof config; + } +} +``` + +The connector automatically builds `supportedNetworks` from the configured chains and their default RPC URLs. You do not need to pass RPC URLs manually. + +### Step 3: Set Up Providers in React + +```tsx +import { WagmiProvider } from 'wagmi'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { config } from './wagmi'; + +const queryClient = new QueryClient(); + +function App() { + return ( + + + + + + ); +} +``` + +### Step 4: Connect Wallet + +```tsx +import { useConnect, useConnectors, useConnection, useDisconnect } from 'wagmi'; + +function ConnectButton() { + const { mutate: connect, status, error } = useConnect(); + const connectors = useConnectors(); + const { address, chainId, status: connectionStatus } = useConnection(); + const { disconnect } = useDisconnect(); + + if (connectionStatus === 'connected') { + return ( +
+

Connected: {address}

+

Chain: {chainId}

+ +
+ ); + } + + return ( +
+ {connectors.map((connector) => ( + + ))} + {status === 'pending' &&

Connecting...

} + {error &&

Error: {error.message}

} +
+ ); +} +``` + +### Step 5: Switch Chains + +```tsx +import { useSwitchChain, useChains, useChainId } from 'wagmi'; + +function ChainSwitcher() { + const chainId = useChainId(); + const chains = useChains(); + const { switchChain, error } = useSwitchChain(); + + return ( +
+ {chains.map((chain) => ( + + ))} + {error &&

{error.message}

} +
+ ); +} +``` + +### Step 6: Sign Messages + +```tsx +import { useSignMessage } from 'wagmi'; + +function SignMessage() { + const { data, signMessage, error, isPending } = useSignMessage(); + + return ( +
+ + {data &&

Signature: {data}

} + {error &&

Error: {error.message}

} +
+ ); +} +``` + +### Step 7: Send Transactions + +```tsx +import { useSendTransaction, useWaitForTransactionReceipt } from 'wagmi'; +import { parseEther } from 'viem'; + +function SendTransaction() { + const { + data: hash, + sendTransaction, + isPending, + error, + } = useSendTransaction(); + const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({ + hash, + }); + + return ( +
+ + {isConfirming &&

Confirming...

} + {isSuccess &&

Confirmed! Hash: {hash}

} + {error &&

Error: {error.message}

} +
+ ); +} +``` + +### Step 8: Connect and Sign (Optional) + +Use `connectAndSign` to prompt the user to connect and sign a message in a single flow: + +```typescript +metaMask({ + dapp: { name: 'My Dapp' }, + connectAndSign: 'By signing this message, you agree to our Terms of Service.', +}); +``` + +The signed message is emitted on the provider as a `'connectAndSign'` event: + +```typescript +const connector = config.connectors[0]; +const provider = await connector.getProvider(); +provider.on('connectAndSign', ({ accounts, chainId, signature }) => { + console.log('Connected accounts:', accounts); + console.log('Chain ID:', chainId); // hex string e.g. '0x1' + console.log('Signature:', signature); +}); +``` + +### Step 9: ConnectWith (Optional) + +Use `connectWith` to connect and execute an RPC method in a single flow: + +```typescript +metaMask({ + dapp: { name: 'My Dapp' }, + connectWith: { + method: 'eth_signTypedData_v4', + params: [address, JSON.stringify(typedData)], + }, +}); +``` + +## MetaMask Connector Parameters Reference + +```typescript +type MetaMaskParameters = { + dapp?: { + name: string; + url?: string; + iconUrl?: string; + }; + debug?: boolean; + mobile?: { + preferredOpenLink?: (deeplink: string, target?: string) => void; + useDeeplink?: boolean; + }; + ui?: { + headless?: boolean; + preferExtension?: boolean; + showInstallModal?: boolean; + }; + // One of: + connectAndSign?: string; + // OR + connectWith?: { method: string; params: unknown[] }; + + // Deprecated (still functional): + dappMetadata?: { name: string; url?: string }; // use dapp instead + logging?: unknown; // use debug instead +}; +``` + +## React Native Setup + +For React Native apps using wagmi with MetaMask: + +```typescript +import { Linking } from 'react-native'; +import { metaMask } from 'wagmi/connectors'; + +metaMask({ + dapp: { + name: 'My RN App', + url: 'https://myapp.com', + }, + mobile: { + preferredOpenLink: (link) => Linking.openURL(link), + useDeeplink: true, + }, +}); +``` + +Ensure React Native polyfills are set up per the `react-native-polyfills` rule. + +## Important Notes + +- The connector ID is `'metaMaskSDK'` and the display name is `'MetaMask'` +- The connector RDNS is `['io.metamask', 'io.metamask.mobile']` +- `@metamask/connect-evm` is an optional peer dependency of `@wagmi/connectors` — only needed when you use the `metaMask()` connector, and the installed version must satisfy wagmi's declared peer range (currently `^1.3.0`), not "latest" +- The `supportedNetworks` map is auto-built from wagmi chain config — no manual RPC URL configuration needed +- If no `dapp` config is provided, defaults to `{ name: window.location.hostname, url: window.location.href }` in browsers +- `useAccount()` is deprecated in favor of `useConnection()` — both work but prefer the new name +- `useSwitchAccount()` is deprecated in favor of `useSwitchConnection()` — both work but prefer the new name +- Transport selection is automatic: uses MetaMask extension (postMessage) when available, otherwise MWP (WebSocket relay + QR/deeplinks) +- Error code `4001` = user rejected, `-32002` = request already pending — handle both explicitly diff --git a/skills/metamask-connect/workflows/setup-wagmi.md b/skills/metamask-connect/workflows/setup-wagmi.md new file mode 100644 index 00000000..be4e3000 --- /dev/null +++ b/skills/metamask-connect/workflows/setup-wagmi.md @@ -0,0 +1,252 @@ +# Setup wagmi App with MetaMask + +## When to use + +Use this skill when: + +- Integrating MetaMask with wagmi in a React or React Native app +- Configuring the `metaMask()` wagmi connector for `@metamask/connect-evm` +- Building connect, sign message, send transaction, or switch chain flows +- Debugging wagmi + MetaMask connector issues + +## Workflow + +### Step 1: Install dependencies + +```bash +npm install wagmi @tanstack/react-query viem + +# Install the @metamask/connect-evm version wagmi declares as its peer range +# (check with: npm info @wagmi/connectors peerDependencies — currently ^1.3.0): +npm install @metamask/connect-evm@"^1.3.0" +``` + +The connect-evm-backed `metaMask()` connector requires **wagmi >= 3.6 / `@wagmi/connectors` >= 8**. Match `@metamask/connect-evm` to wagmi's declared optional peer range rather than installing "latest" — the current 2.x line does not satisfy `^1.3.0`. `@metamask/connect-multichain` is installed transitively; you do not need to add it. + +### Step 2: Create wagmi config (browser) + +```typescript +import { createConfig, http } from 'wagmi'; +import { mainnet, sepolia, optimism, celo } from 'wagmi/chains'; +// Requires wagmi >= 3.6 / @wagmi/connectors >= 8. On older wagmi, copy the +// reference connector from connect-monorepo/integrations/wagmi/metamask-connector.ts +import { metaMask } from 'wagmi/connectors'; + +export const wagmiConfig = createConfig({ + chains: [mainnet, sepolia, optimism, celo], + connectors: [ + metaMask({ + dapp: { + name: 'My DApp', + url: typeof window !== 'undefined' ? window.location.href : undefined, + iconUrl: undefined, // optional + }, + mobile: { + preferredOpenLink: undefined, // React Native: (deeplink) => Linking.openURL(deeplink) + useDeeplink: undefined, + }, + connectAndSign: undefined, // optional + connectWith: undefined, // optional { method, params } + debug: false, + }), + ], + transports: { + [mainnet.id]: http(), + [sepolia.id]: http(), + [optimism.id]: http(), + [celo.id]: http(), + }, +}); +``` + +**Connector parameters (`metaMask(parameters?)`):** + +| Parameter | Type | Description | +| -------------------------- | -------------------------- | -------------------------------------------------------- | +| `dapp` | `{ name, url?, iconUrl? }` | DApp metadata. Deprecated: `dappMetadata` maps to `dapp` | +| `mobile.preferredOpenLink` | `(deeplink) => void` | RN: `(deeplink) => Linking.openURL(deeplink)` | +| `mobile.useDeeplink` | `boolean` | Use deeplink for mobile | +| `connectAndSign` | `string` | Optional | +| `connectWith` | `{ method, params }` | Optional | +| `debug` | `boolean` | Enable debug logs | + +**Connector id:** `'metaMaskSDK'`, **name:** `'MetaMask'` + +### Step 3: Provider hierarchy (React) + +```tsx +import { WagmiProvider } from 'wagmi'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + gcTime: 1_000 * 60 * 60 * 24, + networkMode: 'offlineFirst', + refetchOnWindowFocus: false, + retry: 0, + }, + mutations: { networkMode: 'offlineFirst' }, + }, +}); + + + + + +; +``` + +### Step 4: React Native polyfills (before wagmi config) + +Import polyfills **before** any wagmi/viem imports: + +```typescript +// At the very top of your entry file (e.g. index.js or App.tsx) +import 'react-native-get-random-values'; +import { Buffer } from 'buffer'; +global.Buffer = Buffer; +// window, Event, CustomEvent, metro shims as per RN skills +``` + +Add to `metaMask()` params: + +```typescript +import { Linking } from 'react-native'; + +metaMask({ + dapp: { name: 'My DApp', url: '...' }, + mobile: { + preferredOpenLink: (deeplink) => Linking.openURL(deeplink), + }, +}); +``` + +Use `createAsyncStoragePersister` with AsyncStorage instead of localStorage for persistence. + +### Step 5: Full component example + +```tsx +import { + useConnection, + useBalance, + useConnect, + useConnectors, + useDisconnect, + useSendTransaction, + useSignMessage, + useSwitchChain, + useChains, + useChainId, +} from 'wagmi'; +import { parseEther, formatEther } from 'viem'; + +function WalletDemo() { + const { address, isConnected, status } = useConnection(); + const { data: balance } = useBalance({ address }); + const { mutateAsync: connectAsync, status: connectStatus } = useConnect(); + const connectors = useConnectors(); + const { disconnect } = useDisconnect(); + const { sendTransactionAsync } = useSendTransaction(); + const { signMessageAsync } = useSignMessage(); + const { switchChainAsync } = useSwitchChain(); + const chains = useChains(); + const chainId = useChainId(); + + const metaMaskConnector = connectors.find((c) => c.id === 'metaMaskSDK'); + + const handleConnect = async () => { + if (!metaMaskConnector) return; + await connectAsync({ connector: metaMaskConnector, chainId: 1 }); + }; + + const handleSignMessage = async () => { + const sig = await signMessageAsync({ message: 'Hello MetaMask!' }); + console.log(sig); + }; + + const handleSendTx = async () => { + const hash = await sendTransactionAsync({ + to: '0x...' as `0x${string}`, + value: parseEther('0.001'), + }); + console.log(hash); + }; + + const handleSwitchChain = async (id: number) => { + await switchChainAsync({ chainId: id }); + }; + + if (!isConnected) { + return ( + + ); + } + + return ( +
+

Address: {address}

+

Balance: {balance ? formatEther(balance.value) : '—'} ETH

+

Chain: {chainId}

+ + + + +
+ ); +} +``` + +**Wagmi hooks used (v3):** + +- `useConnection`: `address`, `isConnected`, `status` (renamed from `useAccount` in v3) +- `useBalance`: balance for connected account +- `useConnect`: `mutateAsync` (renamed from `connectAsync`), `status` +- `useConnectors`: standalone hook for connector list (removed from `useConnect` in v3) +- `useDisconnect`: `disconnect` +- `useSendTransaction`: send ETH +- `useSignMessage`: sign messages +- `useSwitchChain`: `switchChainAsync` +- `useChains`: standalone hook for chain list (removed from `useSwitchChain` in v3) +- `useWaitForTransactionReceipt`: tx confirmation +- `useChainId`: current chain +- `useBlockNumber`: current block (`watch: true`) + +### Step 6: Connect flow + +```typescript +// wagmi v3: connectors come from useConnectors(), and useConnect() exposes +// mutateAsync (connectAsync was the v2 name) +const connectors = useConnectors(); +const { mutateAsync: connect } = useConnect(); +const metaMaskConnector = connectors.find((c) => c.id === 'metaMaskSDK'); +await connect({ connector: metaMaskConnector, chainId: 1 }); +``` + +### Step 7: Error handling + +Handle common errors: + +- `UserRejectedRequestError` (code 4001) +- `ResourceUnavailableRpcError` (code -32002) +- `SwitchChainError`, `ChainNotConfiguredError` + +## Important Notes + +- **Connector ID is `'metaMaskSDK'`** — always find it with `connectors.find((c) => c.id === 'metaMaskSDK')`. +- **Wagmi disconnect is separate from multichain disconnect** — disconnecting one does not disconnect the other. +- **CRA/Expo import restriction**: Cannot import from outside `src/` — the connector may need to be copied locally. +- **`isAuthorized` retries on mobile**: The connector wraps `getAccounts()` in `withTimeout` (10ms per attempt) and `withRetry` (3 attempts, ~11ms delay between) because the MetaMask mobile provider sometimes doesn't resolve JSON-RPC requests immediately on page load. It returns `false` on failure (does NOT throw) — it resolves in tens of milliseconds, not seconds. +- **Chains in `wagmiConfig` must match chains you use** — wagmi validates against configured chains. +- **React Native**: Import polyfills before wagmi config; add `mobile.preferredOpenLink`; use `createAsyncStoragePersister` with AsyncStorage. Polyfill requirements: `react-native-get-random-values` first (required for RN < 0.72), then `window` shim (required by connect-multichain for platform detection), then `Event`/`CustomEvent` shims (**wagmi-specific** — wagmi dispatches DOM events; not needed for standalone connect-\* usage). Buffer is self-polyfilled by connect-multichain; keep `global.Buffer = Buffer` as a safety net for peer deps. diff --git a/skills/metamask-connect/workflows/sign-evm-message.md b/skills/metamask-connect/workflows/sign-evm-message.md new file mode 100644 index 00000000..a9e6c4b1 --- /dev/null +++ b/skills/metamask-connect/workflows/sign-evm-message.md @@ -0,0 +1,228 @@ +# Sign EVM Messages with MetaMask Connect + +## When to use + +Use this skill when: + +- Signing a plaintext message with `personal_sign` for authentication or verification +- Signing structured EIP-712 typed data with `eth_signTypedData_v4` for permits, orders, or typed messages +- Using the `connectAndSign` shortcut to connect and sign in a single user approval +- Handling signature errors and user rejections + +## Workflow + +### Step 1: Get the provider and connected account + +Ensure the client is connected before requesting a signature: + +```typescript +import { createEVMClient, getInfuraRpcUrls } from '@metamask/connect-evm'; + +const client = await createEVMClient({ + dapp: { name: 'My DApp', url: window.location.href }, + api: { + supportedNetworks: { + ...getInfuraRpcUrls({ + infuraApiKey: 'YOUR_INFURA_KEY', + chainIds: ['0x1'], + }), + }, + }, +}); + +const { accounts } = await client.connect({ chainIds: ['0x1'] }); +const provider = client.getProvider(); +const account = accounts[0]; // Address (0x-prefixed hex) +``` + +### Step 2: Sign with personal_sign + +`personal_sign` signs a UTF-8 message. The params order is `[message, account]` where `message` is a hex-encoded string: + +```typescript +// Convert message to hex +const message = 'Hello, MetaMask!'; +const hexMessage = + '0x' + + Array.from(new TextEncoder().encode(message)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); + +try { + const signature = await provider.request({ + method: 'personal_sign', + params: [hexMessage, account], + }); + + console.log('Signature:', signature); + // signature is a Hex string: 0x... +} catch (err: any) { + if (err.code === 4001) { + console.log('User rejected the signature request'); + return; + } + throw err; +} +``` + +MetaMask also accepts a raw UTF-8 string for the message parameter, but hex encoding is the canonical format per EIP-191. + +### Step 3: Sign EIP-712 typed data with eth_signTypedData_v4 + +Build the full EIP-712 typed data structure with `types`, `primaryType`, `domain`, and `message`, then pass it as a JSON string: + +```typescript +const typedData = { + types: { + EIP712Domain: [ + { name: 'name', type: 'string' }, + { name: 'version', type: 'string' }, + { name: 'chainId', type: 'uint256' }, + { name: 'verifyingContract', type: 'address' }, + ], + Mail: [ + { name: 'from', type: 'Person' }, + { name: 'to', type: 'Person' }, + { name: 'contents', type: 'string' }, + ], + Person: [ + { name: 'name', type: 'string' }, + { name: 'wallet', type: 'address' }, + ], + }, + primaryType: 'Mail', + domain: { + name: 'Ether Mail', + version: '1', + chainId: 1, + verifyingContract: '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC', + }, + message: { + from: { name: 'Alice', wallet: '0xAliceAddress' }, + to: { name: 'Bob', wallet: '0xBobAddress' }, + contents: 'Hello Bob!', + }, +}; + +try { + const signature = await provider.request({ + method: 'eth_signTypedData_v4', + params: [account, JSON.stringify(typedData)], + }); + + console.log('Typed data signature:', signature); +} catch (err: any) { + if (err.code === 4001) { + console.log('User rejected the typed data signature'); + return; + } + throw err; +} +``` + +### Step 4: ERC-20 Permit (EIP-2612) example + +A common use case for `eth_signTypedData_v4` is signing ERC-20 permit approvals: + +```typescript +const permitData = { + types: { + EIP712Domain: [ + { name: 'name', type: 'string' }, + { name: 'version', type: 'string' }, + { name: 'chainId', type: 'uint256' }, + { name: 'verifyingContract', type: 'address' }, + ], + Permit: [ + { name: 'owner', type: 'address' }, + { name: 'spender', type: 'address' }, + { name: 'value', type: 'uint256' }, + { name: 'nonce', type: 'uint256' }, + { name: 'deadline', type: 'uint256' }, + ], + }, + primaryType: 'Permit', + domain: { + name: 'USD Coin', + version: '2', + chainId: 1, + verifyingContract: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', // USDC + }, + message: { + owner: account, + spender: '0xSpenderContractAddress', + value: '1000000', // 1 USDC (6 decimals) + nonce: 0, + deadline: Math.floor(Date.now() / 1000) + 3600, // 1 hour + }, +}; + +const signature = await provider.request({ + method: 'eth_signTypedData_v4', + params: [account, JSON.stringify(permitData)], +}); +``` + +### Step 5: Use connectAndSign for single-approval flow + +`connectAndSign` connects and signs a `personal_sign` message in one user interaction: + +```typescript +// For authentication, never sign a static string — it is replayable. +// Use an EIP-4361 (SIWE) formatted message with a server-issued nonce: +const siweMessage = [ + `${window.location.host} wants you to sign in with your Ethereum account:`, + '', // account is filled by your SIWE library or template + 'Sign in to My DApp', + '', + `URI: ${window.location.origin}`, + 'Version: 1', + `Chain ID: 1`, + `Nonce: ${serverIssuedNonce}`, // fetched from your backend + `Issued At: ${new Date().toISOString()}`, +].join('\n'); + +const { accounts, chainId, signature } = await client.connectAndSign({ + message: siweMessage, + chainIds: ['0x1'], +}); + +console.log('Connected account:', accounts[0]); +console.log('Signature:', signature); +``` + +This is ideal for sign-in-with-Ethereum (SIWE) flows where you want the user to connect and prove ownership in a single step — verify the signature, nonce, and domain server-side. + +### Step 6: Handle errors + +```typescript +try { + const signature = await provider.request({ + method: 'personal_sign', + params: [hexMessage, account], + }); +} catch (err: any) { + switch (err.code) { + case 4001: + // User rejected — show a message, offer retry + break; + case -32002: + // Request pending — another signing request is in progress + break; + default: + // Unexpected error + console.error('Signing failed:', err); + } +} +``` + +## Important Notes + +- **`personal_sign` params order is `[message, account]`** — not `[account, message]`. Getting this wrong will produce an invalid signature or an error. +- **`eth_signTypedData_v4` params are `[account, typedDataJSON]`** — the typed data must be passed as a `JSON.stringify`'d string, not as a raw object. +- **The `EIP712Domain` type must be declared in `types`** even though `primaryType` is never `EIP712Domain`. It defines the domain separator fields. +- **`connectAndSign` only supports `personal_sign`** — for typed data signing during connection, use `connectWith` with `method: 'eth_signTypedData_v4'` instead. +- **Chain IDs in typed data `domain.chainId` are integers** (e.g., `1`), while chain IDs in SDK calls are hex strings (e.g., `'0x1'`). Don't mix them up. +- **Error code 4001 is a deliberate user rejection** — handle gracefully with a retry option. +- **Error code -32002 means a request is pending** — do not fire another sign request until the user responds. +- **Always connect before signing** — `personal_sign` and `eth_signTypedData_v4` require an active account. Call `client.connect()` first or use `connectAndSign`. diff --git a/skills/metamask-connect/workflows/sign-solana-message.md b/skills/metamask-connect/workflows/sign-solana-message.md new file mode 100644 index 00000000..443a623e --- /dev/null +++ b/skills/metamask-connect/workflows/sign-solana-message.md @@ -0,0 +1,156 @@ +# Sign Solana Message with MetaMask + +## When to use + +Use this skill when: + +- Signing an arbitrary message on Solana with MetaMask Connect +- Implementing sign-in-with-Solana or message verification flows +- Using `useWallet().signMessage` in a React app +- Using the `solana:signMessage` wallet-standard feature in a vanilla browser app + +## Workflow + +### Step 1: Encode the message + +Solana message signing requires a `Uint8Array`. Use `TextEncoder` to convert a string: + +```typescript +const message = new TextEncoder().encode( + 'Sign this message to verify your identity', +); +``` + +### Step 2a: Sign with React wallet-adapter (useWallet) + +**Prerequisites:** `createSolanaClient` has been awaited before rendering, `WalletProvider` is configured with `wallets={[]}`, and the user is connected. See [`setup-solana-react.md`](setup-solana-react.md). + +```tsx +import { useWallet } from '@solana/wallet-adapter-react'; + +function SignMessageButton() { + const { signMessage, publicKey, connected } = useWallet(); + + const handleSign = async () => { + if (!signMessage || !publicKey) { + console.error('Wallet does not support signMessage or is not connected'); + return; + } + + try { + const message = new TextEncoder().encode( + 'Hello from MetaMask on Solana!', + ); + const signature = await signMessage(message); + console.log('Signature (bytes):', signature); + console.log('Signature (hex):', Buffer.from(signature).toString('hex')); + console.log('Signer:', publicKey.toBase58()); + } catch (err: any) { + if (err.code === 4001) { + console.log('User rejected the signature request'); + return; + } + console.error('signMessage failed:', err); + } + }; + + return ( + + ); +} +``` + +### Step 2b: Sign with vanilla browser (wallet-standard feature) + +**Prerequisites:** `createSolanaClient` has been called and the wallet is connected via `standard:connect`. See [`setup-solana-browser.md`](setup-solana-browser.md). + +```typescript +import { createSolanaClient } from '@metamask/connect-solana'; + +const solanaClient = await createSolanaClient({ + dapp: { name: 'My DApp', url: window.location.href }, +}); + +const wallet = solanaClient.getWallet(); + +// Connect first +const connectFeature = wallet.features['standard:connect']; +const { accounts } = await connectFeature.connect(); +const account = accounts[0]; + +// Sign the message +const signMessageFeature = wallet.features['solana:signMessage']; + +try { + const message = new TextEncoder().encode('Hello from MetaMask on Solana!'); + + const [{ signature }] = await signMessageFeature.signMessage({ + account, + message, + }); + + console.log('Signature (hex):', Buffer.from(signature).toString('hex')); + console.log('Signer:', account.address); +} catch (err: any) { + if (err.code === 4001) { + console.log('User rejected the signature request'); + } else { + console.error('signMessage failed:', err); + } +} +``` + +### Step 3: Verify the signature (optional) + +Use `tweetnacl` or `@noble/ed25519` to verify the signature off-chain: + +```typescript +import nacl from 'tweetnacl'; + +const message = new TextEncoder().encode('Hello from MetaMask on Solana!'); +const isValid = nacl.sign.detached.verify( + message, + signature, // Uint8Array from signMessage + publicKey.toBytes(), // Uint8Array of the signer's public key +); +console.log('Signature valid:', isValid); +``` + +### Step 4: Error handling + +Handle these common error scenarios: + +| Error | Cause | Action | +| ---------------------------- | --------------------------------------- | ----------------------------------------- | +| Code `4001` | User rejected the request in MetaMask | Show retry UI, do not treat as app error | +| `signMessage` is `undefined` | Wallet does not support message signing | Check `signMessage` exists before calling | +| `publicKey` is `null` | Wallet not connected | Prompt user to connect first | +| Network error | MetaMask Mobile connection interrupted | Retry or reconnect | + +```typescript +try { + const signature = await signMessage(message); +} catch (err: any) { + switch (err.code) { + case 4001: + // User rejected — show retry button + break; + case -32002: + // Request already pending — wait for user to act in MetaMask + break; + default: + console.error('Unexpected error:', err); + } +} +``` + +## Important Notes + +- **Messages must be `Uint8Array`** — use `new TextEncoder().encode(string)` to convert. Do not pass raw strings to `signMessage`. +- **`signMessage` may be `undefined`** — always check that `signMessage` exists on the wallet adapter before calling it. Not all wallets support arbitrary message signing. +- **The signature is Ed25519** — Solana uses Ed25519 signatures. The returned `Uint8Array` is 64 bytes. +- **User rejection is code `4001`** — handle it gracefully with a retry option. Do not log it as an error. +- **Wallet name is `"MetaMask"`** — case-sensitive, used to identify the MetaMask wallet in the adapter list. +- **Solana networks** — mainnet, devnet, and testnet scopes are all modeled by the SDK; non-mainnet availability depends on the connected MetaMask build/version, so handle connection errors rather than assuming a cluster is present. From 47f07ab870b4e1b1195d5b1538884aca9e7fef55 Mon Sep 17 00:00:00 2001 From: Alex Donesky Date: Wed, 17 Jun 2026 13:06:57 -0500 Subject: [PATCH 02/10] docs(skills): fix stale wagmi connect-evm peer range and client wording - Correct the @wagmi/connectors metaMask() optional peer range from the stale ^1.3.0 to the current ^2.1.0 across setup-wagmi, setup-wagmi-connector, migrate-from-sdk, and migrate-wagmi-connector. The old guidance ("do not install 2.x") was inverted and would produce the peer mismatch it warned against; now points readers at `npm info @wagmi/connectors peerDependencies`. - Fix setup-evm-react-native: createEVMClient is not a singleton (only the multichain core is); each call returns a fresh wrapper. - Align Solana sign/send prerequisites with setup-solana-react: createSolanaClient need not resolve before first render (wallet registers ~1s later). --- skills/metamask-connect/workflows/migrate-from-sdk.md | 5 +++-- .../metamask-connect/workflows/migrate-wagmi-connector.md | 2 +- .../metamask-connect/workflows/send-solana-transaction.md | 2 +- .../metamask-connect/workflows/setup-evm-react-native.md | 2 +- .../metamask-connect/workflows/setup-wagmi-connector.md | 8 ++++---- skills/metamask-connect/workflows/setup-wagmi.md | 6 +++--- skills/metamask-connect/workflows/sign-solana-message.md | 2 +- 7 files changed, 14 insertions(+), 13 deletions(-) diff --git a/skills/metamask-connect/workflows/migrate-from-sdk.md b/skills/metamask-connect/workflows/migrate-from-sdk.md index 2aa3b040..4f08c552 100644 --- a/skills/metamask-connect/workflows/migrate-from-sdk.md +++ b/skills/metamask-connect/workflows/migrate-from-sdk.md @@ -67,8 +67,9 @@ import { createSolanaClient } from '@metamask/connect-solana'; ```typescript // Requires wagmi >= 3.6 / @wagmi/connectors >= 8 (the connect-evm-backed // connector), with @metamask/connect-evm installed at wagmi's declared peer -// range (currently ^1.3.0). On older wagmi, copy the reference connector -// from connect-monorepo/integrations/wagmi/metamask-connector.ts. +// range (currently ^2.1.0; confirm with `npm info @wagmi/connectors +// peerDependencies`). On older wagmi, copy the reference connector from +// connect-monorepo/integrations/wagmi/metamask-connector.ts. import { metaMask } from 'wagmi/connectors'; ``` diff --git a/skills/metamask-connect/workflows/migrate-wagmi-connector.md b/skills/metamask-connect/workflows/migrate-wagmi-connector.md index 91616277..7bd909fa 100644 --- a/skills/metamask-connect/workflows/migrate-wagmi-connector.md +++ b/skills/metamask-connect/workflows/migrate-wagmi-connector.md @@ -13,7 +13,7 @@ The MetaMask connector in wagmi has been **completely rewritten**. The underlyin **Key impacts:** -- New optional peer dependency: `@metamask/connect-evm` must be installed explicitly, at a version inside wagmi's declared peer range (check `npm info @wagmi/connectors peerDependencies` — currently `^1.3.0`) +- New optional peer dependency: `@metamask/connect-evm` must be installed explicitly, at a version inside wagmi's declared peer range (check `npm info @wagmi/connectors peerDependencies` — currently `^2.1.0`) - Old dependency `@metamask/sdk` should be removed - Configuration parameter names changed (`dappMetadata` → `dapp`, `useDeeplink` → `mobile.useDeeplink`) - Several deprecated SDK-specific options are removed entirely diff --git a/skills/metamask-connect/workflows/send-solana-transaction.md b/skills/metamask-connect/workflows/send-solana-transaction.md index f25c2f71..2ce3242f 100644 --- a/skills/metamask-connect/workflows/send-solana-transaction.md +++ b/skills/metamask-connect/workflows/send-solana-transaction.md @@ -43,7 +43,7 @@ transaction.feePayer = senderPubkey; ### Step 2a: Send with React wallet-adapter (useWallet) -**Prerequisites:** `createSolanaClient` has been awaited before rendering, `WalletProvider` is configured with `wallets={[]}`, and the user is connected. See [`setup-solana-react.md`](setup-solana-react.md). +**Prerequisites:** `createSolanaClient` has been called at app startup (it need not resolve before the first render — the wallet registers ~1s later via wallet-standard), `WalletProvider` is configured with `wallets={[]}`, and the user is connected. See [`setup-solana-react.md`](setup-solana-react.md). ```tsx import { useWallet, useConnection } from '@solana/wallet-adapter-react'; diff --git a/skills/metamask-connect/workflows/setup-evm-react-native.md b/skills/metamask-connect/workflows/setup-evm-react-native.md index 5378a62c..84fa6b6e 100644 --- a/skills/metamask-connect/workflows/setup-evm-react-native.md +++ b/skills/metamask-connect/workflows/setup-evm-react-native.md @@ -327,6 +327,6 @@ const styles = StyleSheet.create({ - **Chain IDs are always hex strings** — use `'0x1'`, `'0x89'`, `'0xaa36a7'`. Never decimal. - **`0x1` is auto-included** in every `connect()` call. - **The empty module stub** (`src/empty-module.js`) is used for Node built-ins the SDK's transitive dependencies reference but never actually call at runtime in React Native. The `stream` module is the exception — it needs a real shim (`readable-stream`). -- **`createEVMClient` is a singleton** — do not call it on every render or in a component body. Initialize once and store the promise. +- **Create the client once** — `createEVMClient` returns a fresh wrapper on every call (only the underlying multichain core is a singleton), so do not call it on every render or in a component body. Initialize once at startup and store the promise. - **Session restoration** — the EVM client syncs any persisted session before `createEVMClient` resolves; detect restores via the `connect` / `accountsChanged` events (in `eventHandlers` or on the provider). There is no `wallet_sessionChanged` handler on the EVM client — that event belongs to the multichain client. - **iOS requires `Linking` permissions** — ensure your `Info.plist` includes the `metamask` URL scheme in `LSApplicationQueriesSchemes` so `Linking.openURL` can open the MetaMask app. diff --git a/skills/metamask-connect/workflows/setup-wagmi-connector.md b/skills/metamask-connect/workflows/setup-wagmi-connector.md index f4a29ff4..9e890cbe 100644 --- a/skills/metamask-connect/workflows/setup-wagmi-connector.md +++ b/skills/metamask-connect/workflows/setup-wagmi-connector.md @@ -16,11 +16,11 @@ npm install wagmi viem @tanstack/react-query # Check which @metamask/connect-evm range wagmi's connector was built against: npm info @wagmi/connectors peerDependencies -# ... then install a version inside that range (currently ^1.3.0): -npm install @metamask/connect-evm@"^1.3.0" +# ... then install a version inside that range (currently ^2.1.0): +npm install @metamask/connect-evm@"^2.1.0" ``` -The connect-evm-backed `metaMask()` connector ships in **wagmi >= 3.6 / `@wagmi/connectors` >= 8**, which declares `@metamask/connect-evm` as an **optional peer dependency**. Install a version that satisfies wagmi's declared peer range — do **not** install `@metamask/connect-evm@latest` blindly: the current 2.x line does not satisfy `^1.3.0`, and pairing the connector with a major it wasn't built against produces peer warnings and undefined behavior. `@metamask/connect-multichain` is installed transitively by `connect-evm`; you do not need to add it. +The connect-evm-backed `metaMask()` connector ships in **wagmi >= 3.6 / `@wagmi/connectors` >= 8**, which declares `@metamask/connect-evm` as an **optional peer dependency** (currently `^2.1.0`). Install a version that satisfies wagmi's declared peer range — do **not** install `@metamask/connect-evm@latest` blindly: pairing the connector with a major it wasn't built against produces peer warnings and undefined behavior. The range moves as wagmi bumps it, so always confirm with `npm info @wagmi/connectors peerDependencies`. `@metamask/connect-multichain` is installed transitively by `connect-evm`; you do not need to add it. ### Step 2: Create Wagmi Config @@ -294,7 +294,7 @@ Ensure React Native polyfills are set up per the `react-native-polyfills` rule. - The connector ID is `'metaMaskSDK'` and the display name is `'MetaMask'` - The connector RDNS is `['io.metamask', 'io.metamask.mobile']` -- `@metamask/connect-evm` is an optional peer dependency of `@wagmi/connectors` — only needed when you use the `metaMask()` connector, and the installed version must satisfy wagmi's declared peer range (currently `^1.3.0`), not "latest" +- `@metamask/connect-evm` is an optional peer dependency of `@wagmi/connectors` — only needed when you use the `metaMask()` connector, and the installed version must satisfy wagmi's declared peer range (currently `^2.1.0`), not "latest" - The `supportedNetworks` map is auto-built from wagmi chain config — no manual RPC URL configuration needed - If no `dapp` config is provided, defaults to `{ name: window.location.hostname, url: window.location.href }` in browsers - `useAccount()` is deprecated in favor of `useConnection()` — both work but prefer the new name diff --git a/skills/metamask-connect/workflows/setup-wagmi.md b/skills/metamask-connect/workflows/setup-wagmi.md index be4e3000..986dd249 100644 --- a/skills/metamask-connect/workflows/setup-wagmi.md +++ b/skills/metamask-connect/workflows/setup-wagmi.md @@ -17,11 +17,11 @@ Use this skill when: npm install wagmi @tanstack/react-query viem # Install the @metamask/connect-evm version wagmi declares as its peer range -# (check with: npm info @wagmi/connectors peerDependencies — currently ^1.3.0): -npm install @metamask/connect-evm@"^1.3.0" +# (check with: npm info @wagmi/connectors peerDependencies — currently ^2.1.0): +npm install @metamask/connect-evm@"^2.1.0" ``` -The connect-evm-backed `metaMask()` connector requires **wagmi >= 3.6 / `@wagmi/connectors` >= 8**. Match `@metamask/connect-evm` to wagmi's declared optional peer range rather than installing "latest" — the current 2.x line does not satisfy `^1.3.0`. `@metamask/connect-multichain` is installed transitively; you do not need to add it. +The connect-evm-backed `metaMask()` connector requires **wagmi >= 3.6 / `@wagmi/connectors` >= 8**. Match `@metamask/connect-evm` to wagmi's declared optional peer range (currently `^2.1.0`) rather than blindly installing "latest" — the range moves as wagmi bumps it, so always confirm with `npm info @wagmi/connectors peerDependencies`. `@metamask/connect-multichain` is installed transitively; you do not need to add it. ### Step 2: Create wagmi config (browser) diff --git a/skills/metamask-connect/workflows/sign-solana-message.md b/skills/metamask-connect/workflows/sign-solana-message.md index 443a623e..6c885730 100644 --- a/skills/metamask-connect/workflows/sign-solana-message.md +++ b/skills/metamask-connect/workflows/sign-solana-message.md @@ -23,7 +23,7 @@ const message = new TextEncoder().encode( ### Step 2a: Sign with React wallet-adapter (useWallet) -**Prerequisites:** `createSolanaClient` has been awaited before rendering, `WalletProvider` is configured with `wallets={[]}`, and the user is connected. See [`setup-solana-react.md`](setup-solana-react.md). +**Prerequisites:** `createSolanaClient` has been called at app startup (it need not resolve before the first render — the wallet registers ~1s later via wallet-standard), `WalletProvider` is configured with `wallets={[]}`, and the user is connected. See [`setup-solana-react.md`](setup-solana-react.md). ```tsx import { useWallet } from '@solana/wallet-adapter-react'; From 558b87e0f8b0da723e084a43c3e9c3e19f654ecf Mon Sep 17 00:00:00 2001 From: Alex Donesky Date: Wed, 17 Jun 2026 16:06:22 -0500 Subject: [PATCH 03/10] docs(skills): add Content Security Policy guidance Add a Content Security Policy section to conventions.md covering the required relay WebSocket and QR data: URI origins, optional analytics / Stencil style allowances, and a minimal example. Add pointers from the browser-specific setup guides (EVM, Solana, multichain) and a dedicated troubleshooting entry so a CSP-blocked relay (which presents as a hung connection) is diagnosable. --- .../references/conventions.md | 26 +++++++++++++++++++ .../references/troubleshooting.md | 21 +++++++++++++++ .../workflows/setup-evm-browser.md | 1 + .../workflows/setup-multichain.md | 1 + .../workflows/setup-solana-browser.md | 1 + 5 files changed, 50 insertions(+) diff --git a/skills/metamask-connect/references/conventions.md b/skills/metamask-connect/references/conventions.md index 82b657e8..0b976b48 100644 --- a/skills/metamask-connect/references/conventions.md +++ b/skills/metamask-connect/references/conventions.md @@ -529,6 +529,32 @@ resolver: { --- +## Content Security Policy (browser) + +A host page with a strict CSP must allow MetaMask Connect's origins, or browser integrations fail in ways that look like other bugs — a blocked relay socket presents exactly like "connection hangs". Not relevant in Node.js or React Native. + +**Required:** + +- `connect-src wss://mm-sdk-relay.api.cx.metamask.io` — the relay WebSocket used for remote (mobile / no-extension) connections. It cannot be proxied or deferred from within the library, so remote connections fail without it. +- `img-src data:` — the install/QR modal in `@metamask/multichain-ui` embeds the MetaMask fox as a `data:` URI inside the generated QR code (it sets `saveAsBlob: false`), so the QR will not render without it. + +**Also consider:** + +- `connect-src https://mm-sdk-analytics.api.cx.metamask.io` — the `@metamask/analytics` telemetry endpoint, used when analytics are enabled (the default). Unnecessary if you set `analytics: { enabled: false }`. +- `style-src 'unsafe-inline'` — `@metamask/multichain-ui` is built with Stencil, which injects component styles at runtime into Shadow DOM. Strict CSPs without `'unsafe-inline'` (or an equivalent nonce/hash strategy) may break modal styling. +- RPC endpoints you pass to `supportedNetworks` (e.g. `https://*.infura.io` or your own node provider) — add the matching `connect-src` entries for whatever you configure. +- `https://metamask.app.link` and `metamask://` — mobile deeplinks / universal links. These are top-level navigations and not normally subject to `connect-src`, but strict policies using `navigate-to` / `form-action` may need to allow them. + +Minimal example (default analytics endpoint + Infura + install modal): + +``` +connect-src 'self' wss://mm-sdk-relay.api.cx.metamask.io https://mm-sdk-analytics.api.cx.metamask.io https://*.infura.io; +img-src 'self' data:; +style-src 'self' 'unsafe-inline'; +``` + +--- + ## MetaMask Connect Testing Patterns > Testing patterns for MetaMask Connect SDK — provider mocking, client mocking, singleton cleanup, and event testing diff --git a/skills/metamask-connect/references/troubleshooting.md b/skills/metamask-connect/references/troubleshooting.md index 6706f48c..5f0aade8 100644 --- a/skills/metamask-connect/references/troubleshooting.md +++ b/skills/metamask-connect/references/troubleshooting.md @@ -466,6 +466,27 @@ npm install @metamask/connect-multichain@^1.0.0 --- +### 22. Connection hangs or QR fails to render under a strict Content-Security-Policy + +**Cause:** The host page's CSP blocks an origin MetaMask Connect needs. A blocked relay socket presents exactly like a hung connection (see #1); a blocked icon or style breaks the QR modal (related to #14 / #16): + +- `connect-src` missing `wss://mm-sdk-relay.api.cx.metamask.io` → remote (mobile / no-extension) connections never establish. +- `img-src` missing `data:` → the QR code's embedded MetaMask icon (a `data:` URI) fails, so the QR does not render. +- `style-src` missing `'unsafe-inline'` → the `@metamask/multichain-ui` modal (Stencil, runtime-injected styles) renders unstyled. +- `connect-src` missing `https://mm-sdk-analytics.api.cx.metamask.io` → analytics requests are blocked (harmless to the connection; only relevant when analytics are enabled). + +**Fix:** Allow the required origins (browser only — CSP does not apply in Node.js / React Native): + +``` +connect-src 'self' wss://mm-sdk-relay.api.cx.metamask.io https://mm-sdk-analytics.api.cx.metamask.io https://*.infura.io; +img-src 'self' data:; +style-src 'self' 'unsafe-inline'; +``` + +Add `connect-src` entries for any custom RPC endpoints you pass to `supportedNetworks`. See the Content Security Policy section in [conventions.md](conventions.md) for the full breakdown. + +--- + ## Diagnostic Checklist Run through this checklist when any MetaMask Connect integration is misbehaving: diff --git a/skills/metamask-connect/workflows/setup-evm-browser.md b/skills/metamask-connect/workflows/setup-evm-browser.md index 69153a8b..3b5c80e8 100644 --- a/skills/metamask-connect/workflows/setup-evm-browser.md +++ b/skills/metamask-connect/workflows/setup-evm-browser.md @@ -295,5 +295,6 @@ document - **Register event listeners before connecting** — set up `accountsChanged`, `chainChanged`, and `disconnect` handlers immediately after getting the provider. - **`chainConfiguration` is a fallback, not a forced add** — it is only used if the wallet doesn't already have the chain configured. If the chain exists, only `wallet_switchEthereumChain` fires. - **Page reloads restore automatically** — the EVM client syncs any persisted session before `createEVMClient` resolves and re-emits `connect`/`accountsChanged` on the provider. The EVM client has no `.on()` method and no `wallet_sessionChanged` handler — use the provider events (or `eventHandlers.connect`) to restore UI state. +- **Content Security Policy** — if your host page sets a strict CSP, you must allow the relay socket (`connect-src wss://mm-sdk-relay.api.cx.metamask.io`) and the QR icon (`img-src data:`); a blocked relay looks like a hung connection. See the Content Security Policy section in [../references/conventions.md](../references/conventions.md). - **Error code 4001** means the user deliberately rejected — show a retry option, not a crash screen. - **Error code -32002** means a request is already pending — do not send another `connect()`. Wait for the user to respond in MetaMask. diff --git a/skills/metamask-connect/workflows/setup-multichain.md b/skills/metamask-connect/workflows/setup-multichain.md index acbe5442..b4928cfb 100644 --- a/skills/metamask-connect/workflows/setup-multichain.md +++ b/skills/metamask-connect/workflows/setup-multichain.md @@ -299,3 +299,4 @@ try { - **Selective disconnect:** Passing specific scopes only revokes those scopes. Omit arguments to fully terminate the session. - **Node.js / React Native:** `dapp.url` is **required** in non-browser environments (there is no `window.location`). - **Solana networks:** mainnet, devnet, and testnet scopes are all modeled by the SDK; non-mainnet availability depends on the connected MetaMask build/version, so handle connection errors rather than assuming a cluster is present. +- **Content Security Policy (browser):** under a strict CSP, allow the relay socket (`connect-src wss://mm-sdk-relay.api.cx.metamask.io`) and the QR icon (`img-src data:`) — a blocked relay looks like a hung connection. See the Content Security Policy section in [../references/conventions.md](../references/conventions.md). diff --git a/skills/metamask-connect/workflows/setup-solana-browser.md b/skills/metamask-connect/workflows/setup-solana-browser.md index 10f2c4cd..6a47c9a4 100644 --- a/skills/metamask-connect/workflows/setup-solana-browser.md +++ b/skills/metamask-connect/workflows/setup-solana-browser.md @@ -258,6 +258,7 @@ main().catch(console.error); - **Injected Solana provider wins** — since `@metamask/connect-solana` 1.0.0, if an injected Solana provider is already present (e.g. the MetaMask browser extension), `createSolanaClient` will not announce its own wallet-standard provider. Don't expect two `"MetaMask"` entries in the registered wallets list. - **Eager provider initialization + stable `getWallet()`** — since `@metamask/connect-solana` 1.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 (no need to wait for `wallet_sessionChanged` on cold start). `getWallet()` also returns the same wallet instance on every call now — safe to cache in a module-level constant, no need to re-await or recreate on subsequent access. - **`disconnect()` only revokes Solana scopes** — EVM sessions managed by other clients remain active. +- **Content Security Policy** — if your host page sets a strict CSP, you must allow the relay socket (`connect-src wss://mm-sdk-relay.api.cx.metamask.io`) and the QR icon (`img-src data:`); a blocked relay looks like a hung connection. See the Content Security Policy section in [../references/conventions.md](../references/conventions.md). - **Chrome on Android** has a known bug where the page may unload during the connection flow. Add a `beforeunload` listener as a workaround: ```typescript window.addEventListener('beforeunload', (e) => { From cf1842539bb7258dc5bbb8c2b1e5ea210dad38d3 Mon Sep 17 00:00:00 2001 From: Alex Donesky Date: Wed, 17 Jun 2026 16:11:37 -0500 Subject: [PATCH 04/10] docs(skills): add Node.js (CLI / server) setup workflow Add workflows/setup-node.md covering headless integration: terminal ASCII QR connect via MetaMask Mobile, createMultichainClient as the recommended Node entry point (connect-multichain/connect-solana ship node builds; connect-evm delegates to the multichain node build), EVM/Solana signing through invokeMethod, the in-memory default storage caveat (no persistence across restarts) and how to supply a custom StoreClient, and the absence of polyfill/CSP requirements. Wire it into SKILL.md with a routing row and a "When to use" bullet. --- skills/metamask-connect/SKILL.md | 2 + .../metamask-connect/workflows/setup-node.md | 149 ++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 skills/metamask-connect/workflows/setup-node.md diff --git a/skills/metamask-connect/SKILL.md b/skills/metamask-connect/SKILL.md index 7af11c56..54c60e61 100644 --- a/skills/metamask-connect/SKILL.md +++ b/skills/metamask-connect/SKILL.md @@ -8,6 +8,7 @@ description: Build dApps that integrate MetaMask via the MetaMask Connect SDK ## When to use - You want to set up a dApp's MetaMask integration — EVM, Solana, or both (multichain) — in vanilla browser JS/TS, React, or React Native +- You want a headless integration — a Node.js CLI, server, or bot that connects to MetaMask Mobile via a terminal QR code - You want to connect/disconnect, manage the provider and session state, or switch chains - You want to sign messages (`personal_sign`, `eth_signTypedData_v4`, Solana `signMessage`) — e.g. Sign-In With Ethereum or nonce auth - You want to send transactions (`eth_sendTransaction`, Solana `sendTransaction` / `signAndSendTransaction`) @@ -42,6 +43,7 @@ Before writing or reviewing **any** MetaMask Connect code, read [references/conv | Solana dApp — React | [workflows/setup-solana-react.md](workflows/setup-solana-react.md) | | Solana dApp — React Native | [workflows/setup-solana-react-native.md](workflows/setup-solana-react-native.md) | | EVM + Solana (multichain) | [workflows/setup-multichain.md](workflows/setup-multichain.md) | +| Node.js CLI / server (headless) | [workflows/setup-node.md](workflows/setup-node.md) | | wagmi app | [workflows/setup-wagmi.md](workflows/setup-wagmi.md) | | wagmi + the connect-evm connector | [workflows/setup-wagmi-connector.md](workflows/setup-wagmi-connector.md) | diff --git a/skills/metamask-connect/workflows/setup-node.md b/skills/metamask-connect/workflows/setup-node.md new file mode 100644 index 00000000..1cd68aa9 --- /dev/null +++ b/skills/metamask-connect/workflows/setup-node.md @@ -0,0 +1,149 @@ +# Setup MetaMask Connect in Node.js (CLI / server) + +## When to use + +Use this skill when: + +- Building a **headless** integration — a CLI tool, backend script, bot, or server-side process — with no browser DOM +- You want a Node process to connect to **MetaMask Mobile** by printing a QR code to the terminal +- You need EVM and/or Solana signing/sending from Node via the multichain client's `invokeMethod` +- You're porting browser MetaMask Connect code to a Node runtime and need to know what changes + +In Node there is **no browser extension and no injected provider** — the only transport is the remote (mobile) flow: the SDK prints an ASCII QR code to the terminal, the user scans it with MetaMask Mobile, and the session runs over the relay WebSocket. + +## Which client to use + +`@metamask/connect-multichain` and `@metamask/connect-solana` ship dedicated **Node builds** (their package `exports` resolve a `node`/`import`/`require` target). `@metamask/connect-evm` ships a single environment-agnostic bundle that keeps `@metamask/connect-multichain` as an external dependency, so in Node it delegates to the multichain **node** build and runs headlessly too (the repo's `node-playground` exercises all three). + +**Recommendation:** use `createMultichainClient` as the primary Node entry point — it has the most explicit Node packaging and one mental model (`invokeMethod` with CAIP-2 scopes) for both EVM and Solana. `createEVMClient` / `createSolanaClient` also run in Node if you prefer a single-chain surface; when you do, Solana operations go through `client.core.invokeMethod` / `client.core.connect` (the wallet-standard browser registration path is not used in Node). + +## Workflow + +### Step 1: Install + +```bash +npm install @metamask/connect-multichain +``` + +**No polyfills are required.** Unlike React Native, Node provides `Buffer`, `crypto`, and `globalThis` natively, and there is no `window`/DOM to shim. (Content Security Policy is also irrelevant — it's a browser concern.) + +### Step 2: Create the client + +`dapp.url` is **required** in Node — there is no `window.location` to fall back to. The client is created with `await`; `createMultichainClient` is a singleton (later calls merge options but reuse the first `dapp`). + +```typescript +import { + createMultichainClient, + getInfuraRpcUrls, + type SessionData, +} from '@metamask/connect-multichain'; + +const client = await createMultichainClient({ + dapp: { + name: 'My CLI Tool', + url: 'https://my-cli.example.com', // required: no window.location in Node + }, + api: { + supportedNetworks: getInfuraRpcUrls({ + infuraApiKey: process.env.INFURA_API_KEY ?? 'demo', + caipChainIds: ['eip155:1', 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'], + }), + }, +}); +``` + +### Step 3: Listen for session changes, then connect + +Register the listener **before** `connect()`. The connect call prints an ASCII QR code to the terminal (with a live countdown that regenerates on expiry) and resolves once the user approves in MetaMask Mobile. + +```typescript +client.on('wallet_sessionChanged', (session?: SessionData) => { + const scopes = session?.sessionScopes ?? {}; + for (const scope of Object.values(scopes) as { accounts?: string[] }[]) { + // accounts are CAIP-10 strings, e.g. 'eip155:1:0xabc…' or + // 'solana:5eykt4…:
' — the address is the last ':' segment + for (const caipAccount of scope.accounts ?? []) { + console.log('account:', caipAccount); + } + } +}); + +// Positional args: connect(scopes, caipAccountIds) +await client.connect( + ['eip155:1', 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'], + [], +); +``` + +To render the QR yourself instead of the built-in terminal output, create the client with `ui: { headless: true }` and handle the `display_uri` event (see [setup-multichain.md](setup-multichain.md), Step 7). + +### Step 4: Sign / send EVM via `invokeMethod` + +`personal_sign` takes `[messageHex, account]`. Hex-encode the message (the browser's `eth_sendTransaction`/routing rules apply identically — EVM reads route to the RPC node, signing routes to the wallet). + +```typescript +const account = '0xYourAddress'; +const messageHex = `0x${Buffer.from('Hello from Node!', 'utf8').toString('hex')}`; + +const signature = await client.invokeMethod({ + scope: 'eip155:1', + request: { + method: 'personal_sign', + params: [messageHex, account], + }, +}); +``` + +### Step 5: Sign / send Solana via `invokeMethod` + +Solana messages are **base64-encoded**, params take an `account: { address }` object, and method names have no `solana_` prefix. All Solana methods route through the wallet. + +```typescript +const SOLANA_MAINNET = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'; +const messageBase64 = Buffer.from('Hello from Node!', 'utf8').toString('base64'); + +const result = await client.invokeMethod({ + scope: SOLANA_MAINNET, + request: { + method: 'signMessage', + params: { + account: { address: 'YourSolanaAddress' }, + message: messageBase64, + }, + }, +}); +``` + +See [multichain-evm-operations.md](multichain-evm-operations.md) and [multichain-solana-operations.md](multichain-solana-operations.md) for the full method/param/return reference and `RPCInvokeMethodErr` handling. + +### Step 6: Disconnect + +```typescript +await client.disconnect(); // revoke all scopes, end the session +``` + +## Session persistence across runs + +The default Node storage adapter (`StoreAdapterNode`) keeps session/transport metadata in an **in-memory `Map`**, so it does **not** survive a process restart — each run of your CLI starts fresh and shows a new QR code. + +To keep a session alive between runs (e.g. so a recurring job doesn't re-prompt), pass your own `storage` — a `StoreClient` implementation (the package exports the `StoreClient` and `StoreAdapter` base classes to extend) backed by durable storage such as a file: + +```typescript +const client = await createMultichainClient({ + dapp: { name: 'My CLI Tool', url: 'https://my-cli.example.com' }, + api: { + /* … */ + }, + storage: myFileBackedStoreClient, // implements StoreClient +}); +``` + +## Important Notes + +- **Remote transport only** — no extension, no injected provider in Node. Connection always goes through MetaMask Mobile via the relay WebSocket; the SDK prints an ASCII QR code (via `@paulmillr/qr`) to the terminal for the user to scan. +- **`dapp.url` is required** — there is no `window.location` in Node. +- **No polyfills, no CSP** — Node has `Buffer`/`crypto`/`globalThis` natively; CSP is a browser-only concern. +- **Default storage is in-memory** — sessions are not persisted across process restarts unless you supply a custom `storage` (`StoreClient`). +- **`createMultichainClient` is a singleton** — create once at startup; later calls merge options but reuse the first `dapp`. +- **EVM read vs. sign routing is unchanged** — EVM reads (`eth_call`, `eth_getBalance`, …) route to the RPC node in `supportedNetworks`; signing methods and all Solana methods route to the wallet. +- **Error handling is identical to browser multichain** — handle `4001` (user rejected) on `connect()`, and `RPCInvokeMethodErr` (original code on `err.rpcCode`) on `invokeMethod`. See [setup-multichain.md](setup-multichain.md), Step 9. From c1952d1c553e63e30516e651440f535ab2332339 Mon Sep 17 00:00:00 2001 From: Alex Donesky Date: Thu, 18 Jun 2026 09:47:17 -0500 Subject: [PATCH 05/10] docs(skills): fix prettier formatting in setup-node.md Wrap the long Buffer.from(...).toString('base64') line in the Solana invokeMethod example so it satisfies the repo's prettier (lint:misc) 80-col rule. Resolves the failing Lint CI job. --- skills/metamask-connect/workflows/setup-node.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/skills/metamask-connect/workflows/setup-node.md b/skills/metamask-connect/workflows/setup-node.md index 1cd68aa9..aabe8ec0 100644 --- a/skills/metamask-connect/workflows/setup-node.md +++ b/skills/metamask-connect/workflows/setup-node.md @@ -100,7 +100,9 @@ Solana messages are **base64-encoded**, params take an `account: { address }` ob ```typescript const SOLANA_MAINNET = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'; -const messageBase64 = Buffer.from('Hello from Node!', 'utf8').toString('base64'); +const messageBase64 = Buffer.from('Hello from Node!', 'utf8').toString( + 'base64', +); const result = await client.invokeMethod({ scope: SOLANA_MAINNET, From 702ca6f6adad44e8779e12abd85914e215fb1804 Mon Sep 17 00:00:00 2001 From: Alex Donesky Date: Thu, 18 Jun 2026 10:25:06 -0500 Subject: [PATCH 06/10] docs(skills): split conventions.md into focused references; dedupe cross-cutting blocks Reduce documentation drift surface by giving each topic a single canonical home, per the Agent Skills guidance (focused reference files, TOCs >300 lines) and mirroring MetaMask/smart-accounts-kit#264's domain-split refs. - Split the 610-line conventions.md into 7 focused, domain-organized references (evm, events, multichain, solana, react-native, csp, testing), each with its own table of contents. conventions.md is now a 93-line always-on core (import paths, config, supportedNetworks, singleton, error handling, connection state) plus a topic index routing to the rest. - Repoint the CSP cross-links in setup-evm-browser, setup-solana-browser, setup-multichain, and troubleshooting at the new canonical csp.md. - Point the two React Native setup workflows at react-native.md as the canonical polyfill/Metro reference (keeping their runnable recipes). - Update SKILL.md's always-on pointer to describe the core + focused refs. All skills/**/*.md pass prettier (3.6.2) and every internal link/anchor resolves. --- skills/metamask-connect/SKILL.md | 2 +- .../references/conventions.md | 571 +----------------- skills/metamask-connect/references/csp.md | 25 + skills/metamask-connect/references/events.md | 135 +++++ skills/metamask-connect/references/evm.md | 71 +++ .../metamask-connect/references/multichain.md | 108 ++++ .../references/react-native.md | 118 ++++ skills/metamask-connect/references/solana.md | 63 ++ skills/metamask-connect/references/testing.md | 64 ++ .../references/troubleshooting.md | 2 +- .../workflows/setup-evm-browser.md | 2 +- .../workflows/setup-evm-react-native.md | 2 + .../workflows/setup-multichain.md | 2 +- .../workflows/setup-solana-browser.md | 2 +- .../workflows/setup-solana-react-native.md | 2 + 15 files changed, 620 insertions(+), 549 deletions(-) create mode 100644 skills/metamask-connect/references/csp.md create mode 100644 skills/metamask-connect/references/events.md create mode 100644 skills/metamask-connect/references/evm.md create mode 100644 skills/metamask-connect/references/multichain.md create mode 100644 skills/metamask-connect/references/react-native.md create mode 100644 skills/metamask-connect/references/solana.md create mode 100644 skills/metamask-connect/references/testing.md diff --git a/skills/metamask-connect/SKILL.md b/skills/metamask-connect/SKILL.md index 54c60e61..24614349 100644 --- a/skills/metamask-connect/SKILL.md +++ b/skills/metamask-connect/SKILL.md @@ -30,7 +30,7 @@ Pick the client for your integration: ## Always-on conventions -Before writing or reviewing **any** MetaMask Connect code, read [references/conventions.md](references/conventions.md) — hex chain IDs, `supportedNetworks` validation, EIP-1193 provider events, multichain session lifecycle, Solana constraints, React Native polyfills, and testing patterns. Apply it alongside every workflow below. +Before writing or reviewing **any** MetaMask Connect code, read [references/conventions.md](references/conventions.md) — the always-on core guardrails (import paths, required config, `supportedNetworks`, singleton behavior, error handling, connection state) plus a topic index into focused references. Then load the focused reference(s) for your task: [evm.md](references/evm.md) (chain IDs / `switchChain`), [events.md](references/events.md), [multichain.md](references/multichain.md), [solana.md](references/solana.md), [react-native.md](references/react-native.md) (polyfills / Metro), [csp.md](references/csp.md), [testing.md](references/testing.md). Each topic has a single canonical home, so apply the relevant reference alongside every workflow below. ## Set up (choose your stack) diff --git a/skills/metamask-connect/references/conventions.md b/skills/metamask-connect/references/conventions.md index 0b976b48..d92082eb 100644 --- a/skills/metamask-connect/references/conventions.md +++ b/skills/metamask-connect/references/conventions.md @@ -1,12 +1,27 @@ # MetaMask Connect — Conventions & Guardrails -Always-on guardrails for the MetaMask Connect SDK, distilled from the [MetaMask Connect Cursor plugin](https://github.com/MetaMask/metamask-connect-cursor-plugin) rules. Apply these whenever you generate or review MetaMask Connect (`@metamask/connect-evm` / `-multichain` / `-solana`) or wagmi `metaMask()` connector code. +Always-on guardrails for the MetaMask Connect SDK, distilled from the [MetaMask Connect Cursor plugin](https://github.com/MetaMask/metamask-connect-cursor-plugin) rules. Apply the core rules below whenever you generate or review MetaMask Connect (`@metamask/connect-evm` / `-multichain` / `-solana`) or wagmi `metaMask()` connector code. Deeper, domain-specific guidance lives in the focused reference files indexed below — read the one(s) relevant to your task. -## MetaMask Connect Best Practices +## Topic index -> Best practices for MetaMask Connect SDK — import paths, singleton behavior, required config, error handling, and connection state management +Each topic has a single canonical home. Load the file for the area you're working in: -## Import Paths +| Topic | Reference | +| ---------------------------------------------------------------------------- | ---------------------------------------- | +| EVM chain ID format (hex vs CAIP-2), `switchChain`, validation | [evm.md](evm.md) | +| Event handling — EIP-1193 events, `eventHandlers`, EIP-6963, status | [events.md](events.md) | +| Multichain session lifecycle — singleton, `wallet_sessionChanged`, timeouts | [multichain.md](multichain.md) | +| Solana constraints — wallet adapter, CAIP-2 genesis hashes, RPC routing | [solana.md](solana.md) | +| React Native — polyfills, import order, Metro `extraNodeModules`, `openLink` | [react-native.md](react-native.md) | +| Content Security Policy (browser) origins | [csp.md](csp.md) | +| Testing patterns — mocking, singleton cleanup, test networks | [testing.md](testing.md) | +| Symptom → cause → fix index for connection/polyfill/QR issues | [troubleshooting.md](troubleshooting.md) | + +## Core (always-on) + +These cross-cutting rules apply to every MetaMask Connect integration regardless of stack. + +### Import Paths - Import EVM client from `@metamask/connect-evm` - Import multichain client from `@metamask/connect-multichain` @@ -15,7 +30,7 @@ Always-on guardrails for the MetaMask Connect SDK, distilled from the [MetaMask - Use the wagmi connector from the published entrypoint your installed version exposes; do not assume `@metamask/connect-evm/wagmi` exists unless your package version exports it - `@metamask/connect-multichain` is a **regular dependency** of both `@metamask/connect-evm` and `@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-multichain` resolutions; if you do depend on it directly (e.g. to use `createMultichainClient`), use `^1.0.0` — it is a stable 1.x package following strict semver -## Required Configuration +### Required Configuration - `dapp.name` is always required — it appears in the MetaMask connection prompt - `dapp.url` is required in Node.js and React Native environments (no `window.location` available) @@ -23,16 +38,16 @@ Always-on guardrails for the MetaMask Connect SDK, distilled from the [MetaMask - `dapp.iconUrl` is optional — displayed in MetaMask connection UI - `dapp.base64Icon` is an alternative to `iconUrl` — pass a base64-encoded icon string directly (useful when a hosted URL is unavailable, e.g., in React Native) -## Supported Networks +### Supported Networks - Every chain the dApp interacts with must be in `api.supportedNetworks` with a reachable RPC URL - Use `getInfuraRpcUrls({ infuraApiKey: 'API_KEY', chainIds?: Hex[] })` to populate common EVM chains — it returns a hex-keyed map for `createEVMClient` - Use `getInfuraRpcUrls({ infuraApiKey: 'API_KEY', caipChainIds?: string[] })` to populate CAIP-2 chains for `createMultichainClient` - Use `getInfuraRpcUrls({ infuraApiKey: 'API_KEY', networks: SolanaNetwork[] })` from `@metamask/connect-solana` to populate a network-name-keyed map for `createSolanaClient` — `networks` is required - Chain `0x1` (Ethereum mainnet) is auto-included in the EVM `connect()` permission request if not specified — but it is **not** auto-added to `supportedNetworks`, which must list every chain explicitly -- Making an RPC request whose active chain is missing from `supportedNetworks` throws "not configured in supportedNetworks" (the check runs in the provider's `request()` path, not in `connect()`) +- Making an RPC request whose active chain is missing from `supportedNetworks` throws "not configured in supportedNetworks" (the check runs in the provider's `request()` path, not in `connect()`). See [evm.md → Validation Error](evm.md#validation-error) -## Singleton Behavior +### Singleton Behavior - `createMultichainClient` is the singleton shared core instance - `createEVMClient` and `createSolanaClient` create chain-specific wrappers on top of that shared multichain core @@ -40,8 +55,9 @@ Always-on guardrails for the MetaMask Connect SDK, distilled from the [MetaMask - The multichain core keeps the `dapp` object from the first call and does not overwrite it later - Never call `create*Client` inside a React component render — call it once at app startup - Do not wrap client creation in `useEffect` or other hooks that may re-run +- Full merge semantics: [multichain.md → Singleton Merging](multichain.md#singleton-merging) -## Error Handling +### Error Handling - Code `4001`: User rejected the request — show retry UI, do not log as application error. On the EVM provider it appears as `err.code`; on the multichain client it appears as `err.rpcCode` (see below) - Code `-32002` ("request already pending") comes from the **extension transport only** — multichain MWP concurrent `connect()` instead throws a plain `Error` ("Existing connection is pending...") with no numeric code @@ -62,7 +78,7 @@ Always-on guardrails for the MetaMask Connect SDK, distilled from the [MetaMask - Other exported error classes: `RPCHttpErr` (code 50), `RPCReadonlyResponseErr` (51), `RPCReadonlyRequestErr` (52) — for RPC-node-routed read calls. (There are no `ProtocolError`/`StorageError`/`RpcError` exports.) -## Connection State +### Connection State - Check connection state before making signing requests - Listen for `wallet_sessionChanged` to track session state reactively @@ -71,540 +87,7 @@ Always-on guardrails for the MetaMask Connect SDK, distilled from the [MetaMask - **EVM client:** `disconnect()` revokes only the `eip155:*` 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 +### Unsupported Methods - The EVM client **rejects** certain methods with `Method: is not supported by Metamask Connect/EVM` (they are not silently ignored) - Since `@metamask/connect-evm` 2.0.0, `wallet_requestPermissions` resolves to a spec-shaped requested-permissions array — but `connect()` 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'` not `1` or `'1'` -- All `chainIds` arrays, `supportedNetworks` keys, and `switchChain` parameters 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:` — always uses decimal, not hex -- EVM RPC / EIP-1193 format uses hex strings (`0x1`) -- Multichain `invokeMethod` scope uses CAIP-2 (`eip155:1`) -- EVM client `connect({ chainIds })` uses hex strings (`['0x1']`) -- Convert: hex `0x89` → decimal `137` → CAIP-2 `eip155:137` - -## Auto-Included Chain - -- `0x1` (Ethereum mainnet) is automatically included in the EVM client's `connect()` **permission request** even if you don't pass it in `chainIds` -- It is **not** injected into `api.supportedNetworks` — that map must explicitly contain every chain you use (including mainnet), and `createEVMClient` throws 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-evm` as a peer dependency -- Use `getInfuraRpcUrls({ infuraApiKey: 'API_KEY', chainIds?: Hex[] })` from `@metamask/connect-evm` to populate `supportedNetworks` — returns a hex-chain-ID-keyed map of Infura RPC URLs (e.g. `{ '0x1': 'https://...', '0x89': 'https://...' }`); `chainIds` is optional and filters to specific hex chain IDs -- The multichain equivalent in `@metamask/connect-multichain` is `getInfuraRpcUrls({ 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_switchEthereumChain` can fail -- Pass `chainConfiguration` directly to `client.switchChain()` as the `wallet_addEthereumChain` fallback payload -- In wagmi flows, the connector passes the same fallback config through to the underlying SDK `switchChain()` call -- Since `@metamask/connect-evm` 1.2.0, calling `switchChain({ chainId })` without a `chainConfiguration` now surfaces the wallet's **original** `Unrecognized chain ID` error (EIP-1193 code `4902`) instead of the previous `No chain configuration found.` wrapper. Catch the raw code in your `catch` block and either retry with a `chainConfiguration` fallback, call `wallet_addEthereumChain` explicitly, or prompt the user to add the chain — do not pattern-match on the legacy `"No chain configuration found"` message string -- Since `@metamask/connect-evm` 2.0.0, MWP-backed (Mobile Wallet Protocol) EIP-1193 requests reject with the wallet's error consistently with the default transport, so `switchChain()` no longer inspects returned error payloads — wallet errors (including `4902`) always arrive as a **rejected promise**. Handle switch-chain failures purely in `catch`; do not check for an error object in the resolved value of `switchChain()` or a `provider.request({ method: 'wallet_switchEthereumChain' })` call - -## Validation Error - -- Making an RPC request whose **active** chain's CAIP scope is missing from `supportedNetworks` throws `Chain eip155: 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** in `connect()`. `connect()` only validates that `chainIds` is a non-empty array, and `wallet_switchEthereumChain` is forwarded to the wallet (it is not gated by `supportedNetworks`). -- Fix: add every chain the dApp reads from to `supportedNetworks` with 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 payload** -- **`accountsChanged`** — 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 - -```typescript -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 - -- `chainChanged` emits a **hex string** (e.g., `'0x1'`, `'0x89'`), **not a decimal number** -- Never compare directly with decimal numbers: `chainId === 1` will 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 connect - - `disconnect` — same as EIP-1193 disconnect - - `accountsChanged` — same as EIP-1193 accountsChanged - - `chainChanged` — same as EIP-1193 chainChanged - - `displayUri` — fires with the connection URI string for QR code rendering - - `connectAndSign` — fires with the signature result from `connectAndSign` flow - - `connectWith` — fires with the result from `connectWith` flow - -```typescript -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_uri` only fires during the `'connecting'` state — between calling `connect()` and the connection resolving -- Register the `display_uri` listener **before** calling `connect()` — 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_uri` is mainly useful in headless mode - -## Multichain stateChanged Event - -- The multichain core client emits `stateChanged` whenever the connection status changes -- Listen via `client.on('stateChanged', (status) => ...)` on the multichain client, where `status` is a `ConnectionStatus` string -- 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 - -## 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 `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 - -## EIP-6963 Provider Announcement - -- 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 -- 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: 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) -- 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 - -## Cached State Methods - -- `eth_accounts` and `eth_chainId` return locally cached state from the SDK rather than making RPC calls -- The cached values are kept in sync via `accountsChanged` and `chainChanged` events, so they reflect the current state after connection -- Use `client.getChainId()` to get the current hex chain ID (returns `Hex | undefined`) -- Use `client.getAccount()` to get the current account address (returns `Address | undefined`) -- 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 -- 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 - -## Client Status Property - -- 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`) -- On the multichain client (`createMultichainClient`), `client.status` is the 5-value `ConnectionStatus`: `'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 `useEffect` cleanup to remove listeners: - -```typescript -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 - -- `createMultichainClient` is a singleton — calling it multiple times returns the same instance -- On subsequent calls, new options merge into the existing instance -- The `dapp` object from the first call is used for the client's lifetime — it is **excluded from option merging** entirely (later `dapp` values are ignored) -- `api.supportedNetworks` entries merge by spreading the new map over the old — new chains are added and **existing keys are overwritten** by later calls -- Call `createMultichainClient` once 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 MWP `connect()` is pending throws a plain `Error` ("Existing connection is pending. Please check your MetaMask Mobile app to continue.") with **no numeric code** — match on the message. (`-32002` is 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`) — session data arrives via the `wallet_sessionChanged` event or on demand from `client.provider.getSession()` -- Session data is `SessionData`: scopes live under `sessionScopes` (e.g., `session.sessionScopes['eip155:1'].accounts`), and accounts are CAIP-10 strings (`eip155:1:0x...`) -- `sessionProperties` may be present — if empty, it is `undefined` (not an empty object) -- Always null-check `sessionProperties` before accessing its fields -- Since `@metamask/connect-evm` 1.2.0, every `wallet_createSession` request issued by `connect-evm` attaches `sessionProperties: { 'eip1193-compatible': true }`. Sessions established through `createEVMClient` will 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.url` falls back to `window.location.href` if not specified -- In Node.js and React Native, `dapp.url` is **required** — there is no `window.location` to fall back to -- Omitting `dapp.url` in non-browser environments throws `Error: You must provide dapp url` during client creation (in the browser it is auto-filled from `window.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 - -```typescript -// 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_sessionChanged` on startup to detect restored sessions -- Do not call `connect()` again if a session already exists — check session state first -- `createEVMClient` and `createSolanaClient` perform 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_sessionChanged` event - -## Headless Mode - -- Set `ui: { headless: true }` to suppress the default QR code modal -- Register a `display_uri` event listener **before** calling `connect()` to receive the connection URI -- `display_uri` only 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-multichain` 0.13.0, the MWP transport modules — `@metamask/mobile-wallet-protocol-core`, `@metamask/mobile-wallet-protocol-dapp-client`, and `eciesjs` — 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-multichain` 0.14.0, the QR-code MWP flow (desktop web and Node.js) omits the initial `wallet_createSession` request 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 — `forceRequest` is the fourth positional argument -- The multichain `connect` signature is `connect(scopes, caipAccountIds, sessionProperties?, forceRequest?)` — all positional arguments, not an options object -- `wallet_requestPermissions` itself does not take a `forceRequest` parameter; the SDK handles that through `connect()` -- Without `forceRequest`, the SDK may reuse an existing compatible session -- `connect()` internally handles the underlying permission request flow, so you rarely need to call `wallet_requestPermissions` directly -- 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 by `createMultichainClient` (`@metamask/connect-multichain` 0.15.0+), `createEVMClient` (`@metamask/connect-evm` 1.4.0+), and `createSolanaClient` (`@metamask/connect-solana` 1.2.0+) -- Setting `analytics.enabled: false` on `createMultichainClient` also omits the `analytics.remote_session_id` field 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/analytics` 0.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 `createSolanaClient` is `"MetaMask"` (renamed from `"MetaMask Connect"` in `@metamask/connect-solana` 1.0.0). Match on exactly `"MetaMask"` — do not branch on the old `"MetaMask Connect"` literal. -- Since `@metamask/connect-solana` 1.0.0, `createSolanaClient` no 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. -- `WalletProvider` must receive `wallets={[]}` — 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 `createSolanaClient` early in app startup, but it does not need to resolve before the first `WalletProvider` render -- If your UI depends on MetaMask already being registered, gate that UI until `createSolanaClient` resolves -- Since `@metamask/connect-solana` 1.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 separate `wallet_sessionChanged` event to read accounts on cold start -- Since `@metamask/connect-solana` 1.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, React `useRef`, or `useMemo` — do not call `getWallet()` 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 `invokeMethod` and `connect` - -## 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()` / `invokeMethod` errors rather than treating devnet/testnet as guaranteed -- For Solana read calls, point a `@solana/web3.js` `Connection` at 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_getBalance` go to Infura), every Solana `invokeMethod` call 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.js` `Connection` directly 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's `disconnect()` 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-react` on 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`) with `invokeMethod` directly -- Do not attempt to import `@solana/wallet-adapter-react` or `@solana/wallet-adapter-react-ui` in 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) - -```typescript -// 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.getRandomValues` natively -- 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-multichain` self-polyfills `Buffer` via its React Native entry point — not needed for the SDK itself -- Still recommended to set `global.Buffer = Buffer` in `polyfills.ts` as 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 inspects `window` and `global.navigator.product` to 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, but `isSecure()` 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 `eventemitter3` for all internal eventing; DOM `Event`/`CustomEvent` are never constructed in React Native code paths -- **Required when using wagmi** — wagmi core dispatches DOM events internally -- Add only if your integration uses wagmi: - -```typescript -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` -- **`stream`** must map to `readable-stream` (not `stream-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): - -```javascript -// 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-stream` needs to be installed — do not install `react-native-crypto`, `@tradle/react-native-http`, `https-browserify`, or `os-browserify`; they are obsolete for this SDK - -## preferredOpenLink (Required) - -- `mobile.preferredOpenLink` must 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-storage` for session persistence -- With wagmi: use `createAsyncStoragePersister` from `@tanstack/query-async-storage-persister` -- Without wagmi: the MetaMask Connect SDK handles persistence internally when AsyncStorage is provided - ---- - -## Content Security Policy (browser) - -A host page with a strict CSP must allow MetaMask Connect's origins, or browser integrations fail in ways that look like other bugs — a blocked relay socket presents exactly like "connection hangs". Not relevant in Node.js or React Native. - -**Required:** - -- `connect-src wss://mm-sdk-relay.api.cx.metamask.io` — the relay WebSocket used for remote (mobile / no-extension) connections. It cannot be proxied or deferred from within the library, so remote connections fail without it. -- `img-src data:` — the install/QR modal in `@metamask/multichain-ui` embeds the MetaMask fox as a `data:` URI inside the generated QR code (it sets `saveAsBlob: false`), so the QR will not render without it. - -**Also consider:** - -- `connect-src https://mm-sdk-analytics.api.cx.metamask.io` — the `@metamask/analytics` telemetry endpoint, used when analytics are enabled (the default). Unnecessary if you set `analytics: { enabled: false }`. -- `style-src 'unsafe-inline'` — `@metamask/multichain-ui` is built with Stencil, which injects component styles at runtime into Shadow DOM. Strict CSPs without `'unsafe-inline'` (or an equivalent nonce/hash strategy) may break modal styling. -- RPC endpoints you pass to `supportedNetworks` (e.g. `https://*.infura.io` or your own node provider) — add the matching `connect-src` entries for whatever you configure. -- `https://metamask.app.link` and `metamask://` — mobile deeplinks / universal links. These are top-level navigations and not normally subject to `connect-src`, but strict policies using `navigate-to` / `form-action` may need to allow them. - -Minimal example (default analytics endpoint + Infura + install modal): - -``` -connect-src 'self' wss://mm-sdk-relay.api.cx.metamask.io https://mm-sdk-analytics.api.cx.metamask.io https://*.infura.io; -img-src 'self' data:; -style-src 'self' 'unsafe-inline'; -``` - ---- - -## 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 diff --git a/skills/metamask-connect/references/csp.md b/skills/metamask-connect/references/csp.md new file mode 100644 index 00000000..b2ad6d13 --- /dev/null +++ b/skills/metamask-connect/references/csp.md @@ -0,0 +1,25 @@ +# MetaMask Connect — Content Security Policy (browser) + +The canonical reference for CSP origins the SDK needs in the browser. Not relevant in Node.js or React Native. For always-on core guardrails see [conventions.md](conventions.md). + +A host page with a strict CSP must allow MetaMask Connect's origins, or browser integrations fail in ways that look like other bugs — a blocked relay socket presents exactly like "connection hangs". + +**Required:** + +- `connect-src wss://mm-sdk-relay.api.cx.metamask.io` — the relay WebSocket used for remote (mobile / no-extension) connections. It cannot be proxied or deferred from within the library, so remote connections fail without it. +- `img-src data:` — the install/QR modal in `@metamask/multichain-ui` embeds the MetaMask fox as a `data:` URI inside the generated QR code (it sets `saveAsBlob: false`), so the QR will not render without it. + +**Also consider:** + +- `connect-src https://mm-sdk-analytics.api.cx.metamask.io` — the `@metamask/analytics` telemetry endpoint, used when analytics are enabled (the default). Unnecessary if you set `analytics: { enabled: false }`. +- `style-src 'unsafe-inline'` — `@metamask/multichain-ui` is built with Stencil, which injects component styles at runtime into Shadow DOM. Strict CSPs without `'unsafe-inline'` (or an equivalent nonce/hash strategy) may break modal styling. +- RPC endpoints you pass to `supportedNetworks` (e.g. `https://*.infura.io` or your own node provider) — add the matching `connect-src` entries for whatever you configure. +- `https://metamask.app.link` and `metamask://` — mobile deeplinks / universal links. These are top-level navigations and not normally subject to `connect-src`, but strict policies using `navigate-to` / `form-action` may need to allow them. + +Minimal example (default analytics endpoint + Infura + install modal): + +``` +connect-src 'self' wss://mm-sdk-relay.api.cx.metamask.io https://mm-sdk-analytics.api.cx.metamask.io https://*.infura.io; +img-src 'self' data:; +style-src 'self' 'unsafe-inline'; +``` diff --git a/skills/metamask-connect/references/events.md b/skills/metamask-connect/references/events.md new file mode 100644 index 00000000..99518e80 --- /dev/null +++ b/skills/metamask-connect/references/events.md @@ -0,0 +1,135 @@ +# MetaMask Connect — Event Handling + +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). + +## Contents + +- [EIP-1193 Events (EVM Provider)](#eip-1193-events-evm-provider) +- [chainChanged Payload Type](#chainchanged-payload-type) +- [SDK eventHandlers (Client Options)](#sdk-eventhandlers-client-options) +- [display_uri Timing](#display_uri-timing) +- [Multichain stateChanged Event](#multichain-statechanged-event) +- [Transport Events](#transport-events) +- [EIP-6963 Provider Announcement](#eip-6963-provider-announcement) +- [Cached State Methods](#cached-state-methods) +- [Client Status Property](#client-status-property) +- [Event Listener Best Practices](#event-listener-best-practices) + +## 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 payload** +- **`accountsChanged`** — 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 + +```typescript +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 + +- `chainChanged` emits a **hex string** (e.g., `'0x1'`, `'0x89'`), **not a decimal number** +- Never compare directly with decimal numbers: `chainId === 1` will 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 connect + - `disconnect` — same as EIP-1193 disconnect + - `accountsChanged` — same as EIP-1193 accountsChanged + - `chainChanged` — same as EIP-1193 chainChanged + - `displayUri` — fires with the connection URI string for QR code rendering + - `connectAndSign` — fires with the signature result from `connectAndSign` flow + - `connectWith` — fires with the result from `connectWith` flow + +```typescript +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_uri` only fires during the `'connecting'` state — between calling `connect()` and the connection resolving +- Register the `display_uri` listener **before** calling `connect()` — 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_uri` is mainly useful in headless mode + +## Multichain stateChanged Event + +- The multichain core client emits `stateChanged` whenever the connection status changes +- Listen via `client.on('stateChanged', (status) => ...)` on the multichain client, where `status` is a `ConnectionStatus` string +- 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 + +## 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 `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 + +## EIP-6963 Provider Announcement + +- 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 +- 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: 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) +- 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 + +## Cached State Methods + +- `eth_accounts` and `eth_chainId` return locally cached state from the SDK rather than making RPC calls +- The cached values are kept in sync via `accountsChanged` and `chainChanged` events, so they reflect the current state after connection +- Use `client.getChainId()` to get the current hex chain ID (returns `Hex | undefined`) +- Use `client.getAccount()` to get the current account address (returns `Address | undefined`) +- 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 +- 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 + +## Client Status Property + +- 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`) +- On the multichain client (`createMultichainClient`), `client.status` is the 5-value `ConnectionStatus`: `'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 `useEffect` cleanup to remove listeners: + +```typescript +useEffect(() => { + const provider = client.getProvider(); + const handler = (accounts: string[]) => setAccounts(accounts); + provider.on('accountsChanged', handler); + return () => provider.removeListener('accountsChanged', handler); +}, [client]); +``` diff --git a/skills/metamask-connect/references/evm.md b/skills/metamask-connect/references/evm.md new file mode 100644 index 00000000..a4153d13 --- /dev/null +++ b/skills/metamask-connect/references/evm.md @@ -0,0 +1,71 @@ +# MetaMask Connect — EVM Chain ID & Switching + +EVM chain ID formatting rules and `switchChain` behavior for `@metamask/connect-evm` and the wagmi `metaMask()` connector. For EVM provider events see [events.md](events.md); for always-on core guardrails (config, error handling, `supportedNetworks`) see [conventions.md](conventions.md). + +## Contents + +- [Hex String Requirement](#hex-string-requirement) +- [Common Chain IDs](#common-chain-ids) +- [CAIP-2 Conversion](#caip-2-conversion) +- [Auto-Included Chain](#auto-included-chain) +- [Wagmi Connector](#wagmi-connector) +- [Switch Chain Fallback](#switch-chain-fallback) +- [Validation Error](#validation-error) + +## Hex String Requirement + +- Chain IDs in MetaMask Connect must always be hex strings: `'0x1'` not `1` or `'1'` +- All `chainIds` arrays, `supportedNetworks` keys, and `switchChain` parameters 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:` — always uses decimal, not hex +- EVM RPC / EIP-1193 format uses hex strings (`0x1`) +- Multichain `invokeMethod` scope uses CAIP-2 (`eip155:1`) +- EVM client `connect({ chainIds })` uses hex strings (`['0x1']`) +- Convert: hex `0x89` → decimal `137` → CAIP-2 `eip155:137` + +## Auto-Included Chain + +- `0x1` (Ethereum mainnet) is automatically included in the EVM client's `connect()` **permission request** even if you don't pass it in `chainIds` +- It is **not** injected into `api.supportedNetworks` — that map must explicitly contain every chain you use (including mainnet), and `createEVMClient` throws 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-evm` as a peer dependency +- Use `getInfuraRpcUrls({ infuraApiKey: 'API_KEY', chainIds?: Hex[] })` from `@metamask/connect-evm` to populate `supportedNetworks` — returns a hex-chain-ID-keyed map of Infura RPC URLs (e.g. `{ '0x1': 'https://...', '0x89': 'https://...' }`); `chainIds` is optional and filters to specific hex chain IDs +- The multichain equivalent in `@metamask/connect-multichain` is `getInfuraRpcUrls({ 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_switchEthereumChain` can fail +- Pass `chainConfiguration` directly to `client.switchChain()` as the `wallet_addEthereumChain` fallback payload +- In wagmi flows, the connector passes the same fallback config through to the underlying SDK `switchChain()` call +- Since `@metamask/connect-evm` 1.2.0, calling `switchChain({ chainId })` without a `chainConfiguration` now surfaces the wallet's **original** `Unrecognized chain ID` error (EIP-1193 code `4902`) instead of the previous `No chain configuration found.` wrapper. Catch the raw code in your `catch` block and either retry with a `chainConfiguration` fallback, call `wallet_addEthereumChain` explicitly, or prompt the user to add the chain — do not pattern-match on the legacy `"No chain configuration found"` message string +- Since `@metamask/connect-evm` 2.0.0, MWP-backed (Mobile Wallet Protocol) EIP-1193 requests reject with the wallet's error consistently with the default transport, so `switchChain()` no longer inspects returned error payloads — wallet errors (including `4902`) always arrive as a **rejected promise**. Handle switch-chain failures purely in `catch`; do not check for an error object in the resolved value of `switchChain()` or a `provider.request({ method: 'wallet_switchEthereumChain' })` call + +## Validation Error + +- Making an RPC request whose **active** chain's CAIP scope is missing from `supportedNetworks` throws `Chain eip155: 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** in `connect()`. `connect()` only validates that `chainIds` is a non-empty array, and `wallet_switchEthereumChain` is forwarded to the wallet (it is not gated by `supportedNetworks`). +- Fix: add every chain the dApp reads from to `supportedNetworks` with a valid RPC URL before selecting it diff --git a/skills/metamask-connect/references/multichain.md b/skills/metamask-connect/references/multichain.md new file mode 100644 index 00000000..d1f854e6 --- /dev/null +++ b/skills/metamask-connect/references/multichain.md @@ -0,0 +1,108 @@ +# MetaMask Connect — Multichain Session Lifecycle + +Lifecycle rules for `createMultichainClient`: singleton merging, the concurrent-connect guard, session data shape, `wallet_sessionChanged`, persistence, headless mode, timeouts, lazy transport, permissions, and analytics. For always-on core guardrails (error handling, `supportedNetworks`) see [conventions.md](conventions.md). + +## Contents + +- [Singleton Merging](#singleton-merging) +- [Concurrent Connect Guard](#concurrent-connect-guard) +- [Session Data Shape](#session-data-shape) +- [dapp.url Requirement](#dappurl-requirement) +- [Multichain Events](#multichain-events) +- [Session Persistence and Resumption](#session-persistence-and-resumption) +- [Headless Mode](#headless-mode) +- [Timeouts](#timeouts) +- [Bundle / Lazy-loaded Transport](#bundle--lazy-loaded-transport) +- [Permission Handling](#permission-handling) +- [Analytics](#analytics) + +## Singleton Merging + +- `createMultichainClient` is a singleton — calling it multiple times returns the same instance +- On subsequent calls, new options merge into the existing instance +- The `dapp` object from the first call is used for the client's lifetime — it is **excluded from option merging** entirely (later `dapp` values are ignored) +- `api.supportedNetworks` entries merge by spreading the new map over the old — new chains are added and **existing keys are overwritten** by later calls +- Call `createMultichainClient` once 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 MWP `connect()` is pending throws a plain `Error` ("Existing connection is pending. Please check your MetaMask Mobile app to continue.") with **no numeric code** — match on the message. (`-32002` is 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`) — session data arrives via the `wallet_sessionChanged` event or on demand from `client.provider.getSession()` +- Session data is `SessionData`: scopes live under `sessionScopes` (e.g., `session.sessionScopes['eip155:1'].accounts`), and accounts are CAIP-10 strings (`eip155:1:0x...`) +- `sessionProperties` may be present — if empty, it is `undefined` (not an empty object) +- Always null-check `sessionProperties` before accessing its fields +- Since `@metamask/connect-evm` 1.2.0, every `wallet_createSession` request issued by `connect-evm` attaches `sessionProperties: { 'eip1193-compatible': true }`. Sessions established through `createEVMClient` will 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.url` falls back to `window.location.href` if not specified +- In Node.js and React Native, `dapp.url` is **required** — there is no `window.location` to fall back to +- Omitting `dapp.url` in non-browser environments throws `Error: You must provide dapp url` during client creation (in the browser it is auto-filled from `window.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 + +```typescript +// 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_sessionChanged` on startup to detect restored sessions +- Do not call `connect()` again if a session already exists — check session state first +- `createEVMClient` and `createSolanaClient` perform 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_sessionChanged` event + +## Headless Mode + +- Set `ui: { headless: true }` to suppress the default QR code modal +- Register a `display_uri` event listener **before** calling `connect()` to receive the connection URI +- `display_uri` only 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-multichain` 0.13.0, the MWP transport modules — `@metamask/mobile-wallet-protocol-core`, `@metamask/mobile-wallet-protocol-dapp-client`, and `eciesjs` — 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-multichain` 0.14.0, the QR-code MWP flow (desktop web and Node.js) omits the initial `wallet_createSession` request 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 — `forceRequest` is the fourth positional argument +- The multichain `connect` signature is `connect(scopes, caipAccountIds, sessionProperties?, forceRequest?)` — all positional arguments, not an options object +- `wallet_requestPermissions` itself does not take a `forceRequest` parameter; the SDK handles that through `connect()` +- Without `forceRequest`, the SDK may reuse an existing compatible session +- `connect()` internally handles the underlying permission request flow, so you rarely need to call `wallet_requestPermissions` directly +- 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 by `createMultichainClient` (`@metamask/connect-multichain` 0.15.0+), `createEVMClient` (`@metamask/connect-evm` 1.4.0+), and `createSolanaClient` (`@metamask/connect-solana` 1.2.0+) +- Setting `analytics.enabled: false` on `createMultichainClient` also omits the `analytics.remote_session_id` field 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/analytics` 0.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 diff --git a/skills/metamask-connect/references/react-native.md b/skills/metamask-connect/references/react-native.md new file mode 100644 index 00000000..930ee254 --- /dev/null +++ b/skills/metamask-connect/references/react-native.md @@ -0,0 +1,118 @@ +# MetaMask Connect — React Native Polyfills & Config + +Required polyfills and configuration for the MetaMask Connect SDK in React Native: per-package polyfill matrix, import order, `Buffer`/`window`/`Event` shims, Metro `extraNodeModules`, `preferredOpenLink`, and persistence. This is the canonical reference for the RN setup workflows. For always-on core guardrails see [conventions.md](conventions.md). + +## Contents + +- [Per-Package Polyfill Requirements](#per-package-polyfill-requirements) +- [Import Order (Critical)](#import-order-critical) +- [react-native-get-random-values](#react-native-get-random-values) +- [Buffer Polyfill](#buffer-polyfill) +- [window Object Polyfill](#window-object-polyfill) +- [Event and CustomEvent Polyfills](#event-and-customevent-polyfills) +- [Metro extraNodeModules](#metro-extranodemodules) +- [preferredOpenLink (Required)](#preferredopenlink-required) +- [Async Storage for Persistence](#async-storage-for-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) + +```typescript +// 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.getRandomValues` natively +- 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-multichain` self-polyfills `Buffer` via its React Native entry point — not needed for the SDK itself +- Still recommended to set `global.Buffer = Buffer` in `polyfills.ts` as 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 inspects `window` and `global.navigator.product` to 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, but `isSecure()` 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 `eventemitter3` for all internal eventing; DOM `Event`/`CustomEvent` are never constructed in React Native code paths +- **Required when using wagmi** — wagmi core dispatches DOM events internally +- Add only if your integration uses wagmi: + +```typescript +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` +- **`stream`** must map to `readable-stream` (not `stream-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): + +```javascript +// 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-stream` needs to be installed — do not install `react-native-crypto`, `@tradle/react-native-http`, `https-browserify`, or `os-browserify`; they are obsolete for this SDK + +## preferredOpenLink (Required) + +- `mobile.preferredOpenLink` must 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-storage` for session persistence +- With wagmi: use `createAsyncStoragePersister` from `@tanstack/query-async-storage-persister` +- Without wagmi: the MetaMask Connect SDK handles persistence internally when AsyncStorage is provided diff --git a/skills/metamask-connect/references/solana.md b/skills/metamask-connect/references/solana.md new file mode 100644 index 00000000..95a5cf8f --- /dev/null +++ b/skills/metamask-connect/references/solana.md @@ -0,0 +1,63 @@ +# MetaMask Connect — Solana Constraints + +Constraints for Solana integration: wallet-adapter config, CAIP-2 genesis-hash identifiers, network support per platform, RPC routing, disconnect behavior, and platform limitations. For always-on core guardrails see [conventions.md](conventions.md); for React Native specifics see [react-native.md](react-native.md). + +## Contents + +- [Wallet Adapter Configuration](#wallet-adapter-configuration) +- [CAIP-2 Genesis Hash Identifiers](#caip-2-genesis-hash-identifiers) +- [Devnet and Testnet](#devnet-and-testnet) +- [RPC Routing](#rpc-routing) +- [Disconnect Scopes Behavior](#disconnect-scopes-behavior) +- [Chrome Android Bug](#chrome-android-bug) +- [React Native Limitation](#react-native-limitation) + +## Wallet Adapter Configuration + +- The wallet name registered by `createSolanaClient` is `"MetaMask"` (renamed from `"MetaMask Connect"` in `@metamask/connect-solana` 1.0.0). Match on exactly `"MetaMask"` — do not branch on the old `"MetaMask Connect"` literal. +- Since `@metamask/connect-solana` 1.0.0, `createSolanaClient` no 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. +- `WalletProvider` must receive `wallets={[]}` — 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 `createSolanaClient` early in app startup, but it does not need to resolve before the first `WalletProvider` render +- If your UI depends on MetaMask already being registered, gate that UI until `createSolanaClient` resolves +- Since `@metamask/connect-solana` 1.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 separate `wallet_sessionChanged` event to read accounts on cold start +- Since `@metamask/connect-solana` 1.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, React `useRef`, or `useMemo` — do not call `getWallet()` 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 `invokeMethod` and `connect` + +## 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()` / `invokeMethod` errors rather than treating devnet/testnet as guaranteed +- For Solana read calls, point a `@solana/web3.js` `Connection` at 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_getBalance` go to Infura), every Solana `invokeMethod` call 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.js` `Connection` directly 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's `disconnect()` 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-react` on 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`) with `invokeMethod` directly +- Do not attempt to import `@solana/wallet-adapter-react` or `@solana/wallet-adapter-react-ui` in RN — they depend on browser APIs diff --git a/skills/metamask-connect/references/testing.md b/skills/metamask-connect/references/testing.md new file mode 100644 index 00000000..361050c9 --- /dev/null +++ b/skills/metamask-connect/references/testing.md @@ -0,0 +1,64 @@ +# MetaMask Connect — Testing Patterns + +Testing patterns for the MetaMask Connect SDK: provider mocking, client mocking, singleton cleanup, test networks, async init, error simulation, and event testing. For always-on core guardrails see [conventions.md](conventions.md). + +## Contents + +- [Provider Mocking](#provider-mocking) +- [Client Mocking](#client-mocking) +- [Singleton Cleanup](#singleton-cleanup) +- [Test Networks](#test-networks) +- [Async Client Initialization](#async-client-initialization) +- [Error Simulation](#error-simulation) +- [Event Testing](#event-testing) +- [Solana Testing](#solana-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 diff --git a/skills/metamask-connect/references/troubleshooting.md b/skills/metamask-connect/references/troubleshooting.md index 5f0aade8..d80283d6 100644 --- a/skills/metamask-connect/references/troubleshooting.md +++ b/skills/metamask-connect/references/troubleshooting.md @@ -483,7 +483,7 @@ img-src 'self' data:; style-src 'self' 'unsafe-inline'; ``` -Add `connect-src` entries for any custom RPC endpoints you pass to `supportedNetworks`. See the Content Security Policy section in [conventions.md](conventions.md) for the full breakdown. +Add `connect-src` entries for any custom RPC endpoints you pass to `supportedNetworks`. See [csp.md](csp.md) for the full breakdown. --- diff --git a/skills/metamask-connect/workflows/setup-evm-browser.md b/skills/metamask-connect/workflows/setup-evm-browser.md index 3b5c80e8..0f72bfe4 100644 --- a/skills/metamask-connect/workflows/setup-evm-browser.md +++ b/skills/metamask-connect/workflows/setup-evm-browser.md @@ -295,6 +295,6 @@ document - **Register event listeners before connecting** — set up `accountsChanged`, `chainChanged`, and `disconnect` handlers immediately after getting the provider. - **`chainConfiguration` is a fallback, not a forced add** — it is only used if the wallet doesn't already have the chain configured. If the chain exists, only `wallet_switchEthereumChain` fires. - **Page reloads restore automatically** — the EVM client syncs any persisted session before `createEVMClient` resolves and re-emits `connect`/`accountsChanged` on the provider. The EVM client has no `.on()` method and no `wallet_sessionChanged` handler — use the provider events (or `eventHandlers.connect`) to restore UI state. -- **Content Security Policy** — if your host page sets a strict CSP, you must allow the relay socket (`connect-src wss://mm-sdk-relay.api.cx.metamask.io`) and the QR icon (`img-src data:`); a blocked relay looks like a hung connection. See the Content Security Policy section in [../references/conventions.md](../references/conventions.md). +- **Content Security Policy** — if your host page sets a strict CSP, you must allow the relay socket (`connect-src wss://mm-sdk-relay.api.cx.metamask.io`) and the QR icon (`img-src data:`); a blocked relay looks like a hung connection. See [../references/csp.md](../references/csp.md). - **Error code 4001** means the user deliberately rejected — show a retry option, not a crash screen. - **Error code -32002** means a request is already pending — do not send another `connect()`. Wait for the user to respond in MetaMask. diff --git a/skills/metamask-connect/workflows/setup-evm-react-native.md b/skills/metamask-connect/workflows/setup-evm-react-native.md index 84fa6b6e..99401f6f 100644 --- a/skills/metamask-connect/workflows/setup-evm-react-native.md +++ b/skills/metamask-connect/workflows/setup-evm-react-native.md @@ -21,6 +21,8 @@ npm install @metamask/connect-evm @metamask/connect-multichain react-native-get- `@metamask/connect-multichain` is installed transitively by `@metamask/connect-evm` (only the 2.0.0 release briefly made it a peer dependency; 2.1.0 reverted that) — installing it explicitly is harmless but not required. The SDK warns at runtime on duplicate or mismatched copies. `react-native-get-random-values` provides `crypto.getRandomValues` — strictly required only on React Native < 0.72 (Hermes 0.72+ ships `globalThis.crypto.getRandomValues` natively), but recommended as a safety net on all versions. It **must** be imported before any other SDK-related code. `readable-stream` provides the `stream` shim for Metro. `buffer` is recommended as a safety net for peer dependencies — `@metamask/connect-multichain` self-polyfills Buffer internally, but other deps (e.g. `eciesjs`) may load before it. `@react-native-async-storage/async-storage` is needed for session persistence. +> The canonical reference for React Native polyfills — the per-package matrix (which ones you actually need), import-order reasoning, the `Buffer`/`window`/`Event` shims, Metro `extraNodeModules`, and `preferredOpenLink` — is [../references/react-native.md](../references/react-native.md). The steps below are the runnable EVM-specific recipe; consult that file for the rules behind each shim. + ### Step 2: Create polyfills.ts Create `src/polyfills.ts` with all required global shims. This file must be imported before anything else: diff --git a/skills/metamask-connect/workflows/setup-multichain.md b/skills/metamask-connect/workflows/setup-multichain.md index b4928cfb..481eb8a1 100644 --- a/skills/metamask-connect/workflows/setup-multichain.md +++ b/skills/metamask-connect/workflows/setup-multichain.md @@ -299,4 +299,4 @@ try { - **Selective disconnect:** Passing specific scopes only revokes those scopes. Omit arguments to fully terminate the session. - **Node.js / React Native:** `dapp.url` is **required** in non-browser environments (there is no `window.location`). - **Solana networks:** mainnet, devnet, and testnet scopes are all modeled by the SDK; non-mainnet availability depends on the connected MetaMask build/version, so handle connection errors rather than assuming a cluster is present. -- **Content Security Policy (browser):** under a strict CSP, allow the relay socket (`connect-src wss://mm-sdk-relay.api.cx.metamask.io`) and the QR icon (`img-src data:`) — a blocked relay looks like a hung connection. See the Content Security Policy section in [../references/conventions.md](../references/conventions.md). +- **Content Security Policy (browser):** under a strict CSP, allow the relay socket (`connect-src wss://mm-sdk-relay.api.cx.metamask.io`) and the QR icon (`img-src data:`) — a blocked relay looks like a hung connection. See [../references/csp.md](../references/csp.md). diff --git a/skills/metamask-connect/workflows/setup-solana-browser.md b/skills/metamask-connect/workflows/setup-solana-browser.md index 6a47c9a4..84b18ec1 100644 --- a/skills/metamask-connect/workflows/setup-solana-browser.md +++ b/skills/metamask-connect/workflows/setup-solana-browser.md @@ -258,7 +258,7 @@ main().catch(console.error); - **Injected Solana provider wins** — since `@metamask/connect-solana` 1.0.0, if an injected Solana provider is already present (e.g. the MetaMask browser extension), `createSolanaClient` will not announce its own wallet-standard provider. Don't expect two `"MetaMask"` entries in the registered wallets list. - **Eager provider initialization + stable `getWallet()`** — since `@metamask/connect-solana` 1.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 (no need to wait for `wallet_sessionChanged` on cold start). `getWallet()` also returns the same wallet instance on every call now — safe to cache in a module-level constant, no need to re-await or recreate on subsequent access. - **`disconnect()` only revokes Solana scopes** — EVM sessions managed by other clients remain active. -- **Content Security Policy** — if your host page sets a strict CSP, you must allow the relay socket (`connect-src wss://mm-sdk-relay.api.cx.metamask.io`) and the QR icon (`img-src data:`); a blocked relay looks like a hung connection. See the Content Security Policy section in [../references/conventions.md](../references/conventions.md). +- **Content Security Policy** — if your host page sets a strict CSP, you must allow the relay socket (`connect-src wss://mm-sdk-relay.api.cx.metamask.io`) and the QR icon (`img-src data:`); a blocked relay looks like a hung connection. See [../references/csp.md](../references/csp.md). - **Chrome on Android** has a known bug where the page may unload during the connection flow. Add a `beforeunload` listener as a workaround: ```typescript window.addEventListener('beforeunload', (e) => { diff --git a/skills/metamask-connect/workflows/setup-solana-react-native.md b/skills/metamask-connect/workflows/setup-solana-react-native.md index 6e8b2885..a408d7bd 100644 --- a/skills/metamask-connect/workflows/setup-solana-react-native.md +++ b/skills/metamask-connect/workflows/setup-solana-react-native.md @@ -19,6 +19,8 @@ npm install @metamask/connect-solana @metamask/connect-multichain @solana/web3.j `@metamask/connect-multichain` is a regular dependency of `@metamask/connect-solana` and is installed transitively — but this skill imports `createMultichainClient` directly (to configure `mobile.preferredOpenLink`, which `createSolanaClient` does not forward), so declare it explicitly to keep strict package managers (pnpm) happy. The SDK warns at runtime if duplicate or mismatched copies are resolved. +> The canonical reference for React Native polyfills — the per-package matrix (which ones you actually need), import-order reasoning, the `Buffer`/`window` shims, Metro `extraNodeModules`, and `preferredOpenLink` — is [../references/react-native.md](../references/react-native.md). The steps below are the runnable Solana-specific recipe; consult that file for the rules behind each shim. (Standalone `connect-solana` does **not** need the `Event`/`CustomEvent` shims — those are wagmi-only.) + ### Step 2: Create the polyfills file Create `polyfills.ts` at the root of your project. From 9e007f2f09b5b49a62e8e80eba374771e8d83dc2 Mon Sep 17 00:00:00 2001 From: Alex Donesky Date: Thu, 18 Jun 2026 15:01:17 -0500 Subject: [PATCH 07/10] docs(skills): fix dangling react-native-polyfills rule references Point migrate-from-sdk and setup-wagmi-connector workflows at the existing references/react-native.md instead of a non-existent rule. --- skills/metamask-connect/workflows/migrate-from-sdk.md | 2 +- skills/metamask-connect/workflows/setup-wagmi-connector.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/skills/metamask-connect/workflows/migrate-from-sdk.md b/skills/metamask-connect/workflows/migrate-from-sdk.md index 4f08c552..3fda2242 100644 --- a/skills/metamask-connect/workflows/migrate-from-sdk.md +++ b/skills/metamask-connect/workflows/migrate-from-sdk.md @@ -31,7 +31,7 @@ npm install @metamask/connect-solana No polyfill configuration is needed for web environments (Vite, Webpack, Next.js, etc.) — `@metamask/connect-*` packages no longer depend on Node.js built-ins in the browser. -**React Native only:** Polyfills must be imported in a specific order. See the `react-native-polyfills` rule for required import order, window/Event/CustomEvent shims, and metro configuration. Note: `Buffer` is self-polyfilled by `@metamask/connect-multichain` but should still be set early as a safety net for peer deps. +**React Native only:** Polyfills must be imported in a specific order. See [../references/react-native.md](../references/react-native.md) for required import order, window/Event/CustomEvent shims, and metro configuration. Note: `Buffer` is self-polyfilled by `@metamask/connect-multichain` but should still be set early as a safety net for peer deps. --- diff --git a/skills/metamask-connect/workflows/setup-wagmi-connector.md b/skills/metamask-connect/workflows/setup-wagmi-connector.md index 9e890cbe..58e94ba8 100644 --- a/skills/metamask-connect/workflows/setup-wagmi-connector.md +++ b/skills/metamask-connect/workflows/setup-wagmi-connector.md @@ -288,7 +288,7 @@ metaMask({ }); ``` -Ensure React Native polyfills are set up per the `react-native-polyfills` rule. +Ensure React Native polyfills are set up per [../references/react-native.md](../references/react-native.md). ## Important Notes From 54e7d97cf1e21b5b7ebbdca14e99fbad5239c67e Mon Sep 17 00:00:00 2001 From: Alex Donesky Date: Thu, 18 Jun 2026 15:05:20 -0500 Subject: [PATCH 08/10] Apply suggestions from code review Co-authored-by: jiexi --- skills/metamask-connect/references/conventions.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/skills/metamask-connect/references/conventions.md b/skills/metamask-connect/references/conventions.md index d92082eb..a6cb5055 100644 --- a/skills/metamask-connect/references/conventions.md +++ b/skills/metamask-connect/references/conventions.md @@ -41,8 +41,8 @@ These cross-cutting rules apply to every MetaMask Connect integration regardless ### Supported Networks - Every chain the dApp interacts with must be in `api.supportedNetworks` with a reachable RPC URL -- Use `getInfuraRpcUrls({ infuraApiKey: 'API_KEY', chainIds?: Hex[] })` to populate common EVM chains — it returns a hex-keyed map for `createEVMClient` -- Use `getInfuraRpcUrls({ infuraApiKey: 'API_KEY', caipChainIds?: string[] })` to populate CAIP-2 chains for `createMultichainClient` +- Use `getInfuraRpcUrls({ infuraApiKey: 'API_KEY', chainIds?: Hex[] })` from `@metamask/connect-evm` to populate common EVM chains — it returns a hex-keyed map for `createEVMClient` +- Use `getInfuraRpcUrls({ infuraApiKey: 'API_KEY', caipChainIds?: string[] })` from `@metamask/connect-multichain` to populate CAIP-2 chains for `createMultichainClient` - Use `getInfuraRpcUrls({ infuraApiKey: 'API_KEY', networks: SolanaNetwork[] })` from `@metamask/connect-solana` to populate a network-name-keyed map for `createSolanaClient` — `networks` is required - Chain `0x1` (Ethereum mainnet) is auto-included in the EVM `connect()` permission request if not specified — but it is **not** auto-added to `supportedNetworks`, which must list every chain explicitly - Making an RPC request whose active chain is missing from `supportedNetworks` throws "not configured in supportedNetworks" (the check runs in the provider's `request()` path, not in `connect()`). See [evm.md → Validation Error](evm.md#validation-error) From b24883f15f367f04d9c93bc7e01a898f6239d700 Mon Sep 17 00:00:00 2001 From: Alex Donesky Date: Thu, 18 Jun 2026 15:13:12 -0500 Subject: [PATCH 09/10] docs(skills): clarify wagmi connector entrypoint and multichain dep Address review feedback on conventions.md import-paths section: - State that the wagmi metaMask() connector comes from wagmi/connectors with connect-evm as an optional peer; connect-evm ships no wagmi entrypoint. - Trim the multichain dependency bullet: drop the imprecise ^1.0.0 pin and 2.0.0 history, keep the load-bearing "installed transitively" fact. --- skills/metamask-connect/references/conventions.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/skills/metamask-connect/references/conventions.md b/skills/metamask-connect/references/conventions.md index a6cb5055..c9330d0a 100644 --- a/skills/metamask-connect/references/conventions.md +++ b/skills/metamask-connect/references/conventions.md @@ -27,8 +27,8 @@ These cross-cutting rules apply to every MetaMask Connect integration regardless - 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/wagmi` exists unless your package version exports it -- `@metamask/connect-multichain` is a **regular dependency** of both `@metamask/connect-evm` and `@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-multichain` resolutions; if you do depend on it directly (e.g. to use `createMultichainClient`), use `^1.0.0` — it is a stable 1.x package following strict semver +- For wagmi, import the `metaMask()` connector from `wagmi/connectors` (wagmi >= 3.6 / `@wagmi/connectors` >= 8) — it uses `@metamask/connect-evm` under the hood as an optional peer dependency. `@metamask/connect-evm` does **not** ship its own wagmi entrypoint (there is no `@metamask/connect-evm/wagmi`). See [setup-wagmi-connector.md](../workflows/setup-wagmi-connector.md) +- `@metamask/connect-multichain` is a **regular (transitive) dependency** of both `@metamask/connect-evm` and `@metamask/connect-solana` — it's installed automatically and you don't need to add it to your `package.json`. (If you import `createMultichainClient` directly, you can still rely on that transitively-installed copy; both clients `console.warn` at runtime if they detect a mismatched or duplicate `@metamask/connect-multichain` resolution.) ### Required Configuration From 223eb0ea56b2ed707ebac897533a22663d24dfe3 Mon Sep 17 00:00:00 2001 From: Alex Donesky Date: Thu, 18 Jun 2026 15:26:06 -0500 Subject: [PATCH 10/10] docs(skills): tighten conventions, solana, events, and multichain refs Address review feedback: - conventions: merge duplicate dapp.url bullets; add Solana disconnect scope behavior; enumerate the EVM methods that are rejected. - solana: rewrite the Chrome Android note to be actionable (the wallet-adapter beforeunload patch is internal and not inherited by consumers); reframe the RN section as a third-party adapter limitation with the multichain/invokeMethod path. - events: reframe intro so it doesn't read as EVM-only; add a Solana state-changes section pointing at core.stateChanged. - multichain: trim the bundle/lazy-transport section to the one actionable guardrail. --- .../metamask-connect/references/conventions.md | 7 +++---- skills/metamask-connect/references/events.md | 9 ++++++++- .../metamask-connect/references/multichain.md | 6 ++---- skills/metamask-connect/references/solana.md | 17 ++++++++--------- 4 files changed, 21 insertions(+), 18 deletions(-) diff --git a/skills/metamask-connect/references/conventions.md b/skills/metamask-connect/references/conventions.md index c9330d0a..8847c4fe 100644 --- a/skills/metamask-connect/references/conventions.md +++ b/skills/metamask-connect/references/conventions.md @@ -33,8 +33,7 @@ These cross-cutting rules apply to every MetaMask Connect integration regardless ### Required Configuration - `dapp.name` is always required — it appears in the MetaMask connection prompt -- `dapp.url` is required in Node.js and React Native environments (no `window.location` available) -- `dapp.url` in browser can default to `window.location.href` but explicit is safer +- `dapp.url` is required in Node.js and React Native (no `window.location`); in the browser it defaults to `window.location.href`, but passing it explicitly is safer - `dapp.iconUrl` is optional — displayed in MetaMask connection UI - `dapp.base64Icon` is an alternative to `iconUrl` — pass a base64-encoded icon string directly (useful when a hosted URL is unavailable, e.g., in React Native) @@ -85,9 +84,9 @@ These cross-cutting rules apply to every MetaMask Connect integration regardless - 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 the `eip155:*` scopes — Solana scopes on the same session survive; full teardown requires the multichain client -- `disconnect(scopes)` with specific scopes only revokes those scopes +- **Solana client:** `disconnect()` revokes only the Solana scopes — EVM scopes on the same session survive; full teardown requires the multichain client ### Unsupported Methods -- The EVM client **rejects** certain methods with `Method: is not supported by Metamask Connect/EVM` (they are not silently ignored) +- The EVM client **rejects** these methods with `Method: is not supported by Metamask Connect/EVM` (they are not silently ignored): `metamask_getProviderState`, `metamask_sendDomainMetadata`, `metamask_logWeb3ShimUsage`, `wallet_registerOnboarding`, `net_version`, `wallet_getPermissions` - Since `@metamask/connect-evm` 2.0.0, `wallet_requestPermissions` resolves to a spec-shaped requested-permissions array — but `connect()` remains the canonical way to establish permissions diff --git a/skills/metamask-connect/references/events.md b/skills/metamask-connect/references/events.md index 99518e80..67763182 100644 --- a/skills/metamask-connect/references/events.md +++ b/skills/metamask-connect/references/events.md @@ -1,6 +1,6 @@ # MetaMask Connect — Event Handling -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). +Event handling across MetaMask Connect. The EVM client is the only family with a first-class event emitter — the EIP-1193 `provider.on(...)` API — so most of this file is EVM-focused: EIP-1193 events, SDK `eventHandlers`, payload types, `display_uri` timing, EIP-6963 announcement, and cached state. Reactive state for **multichain and Solana** flows through the multichain core's `stateChanged` event (see [Multichain stateChanged Event](#multichain-statechanged-event) and [Solana State Changes](#solana-state-changes)). For multichain `wallet_sessionChanged` see [multichain.md](multichain.md); for always-on core guardrails see [conventions.md](conventions.md). ## Contents @@ -9,6 +9,7 @@ EVM provider and `connect-evm` event handling: EIP-1193 events, SDK `eventHandle - [SDK eventHandlers (Client Options)](#sdk-eventhandlers-client-options) - [display_uri Timing](#display_uri-timing) - [Multichain stateChanged Event](#multichain-statechanged-event) +- [Solana State Changes](#solana-state-changes) - [Transport Events](#transport-events) - [EIP-6963 Provider Announcement](#eip-6963-provider-announcement) - [Cached State Methods](#cached-state-methods) @@ -91,6 +92,12 @@ const client = await createEVMClient({ - Listen via `client.on('stateChanged', (status) => ...)` on the multichain client, where `status` is a `ConnectionStatus` string - 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 +## Solana State Changes + +- The Solana client (`createSolanaClient`) does **not** expose its own event emitter. Observe connection/session state via its public `.core` property: `solanaClient.core.on('stateChanged', (status) => ...)` (same `ConnectionStatus` values as the multichain client) +- Since `@metamask/connect-solana` 1.1.0 the client eagerly initializes the wallet provider on creation, so on cold start you can read accounts directly without waiting for a `wallet_sessionChanged` event +- If you integrate through `@solana/wallet-adapter-react`, get account/connection changes from the adapter's own hooks (`useWallet()`), not from a provider `.on(...)` call + ## 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. diff --git a/skills/metamask-connect/references/multichain.md b/skills/metamask-connect/references/multichain.md index d1f854e6..66023eb5 100644 --- a/skills/metamask-connect/references/multichain.md +++ b/skills/metamask-connect/references/multichain.md @@ -86,10 +86,8 @@ client.on('wallet_sessionChanged', (session) => { ## Bundle / Lazy-loaded Transport -- Since `@metamask/connect-multichain` 0.13.0, the MWP transport modules — `@metamask/mobile-wallet-protocol-core`, `@metamask/mobile-wallet-protocol-dapp-client`, and `eciesjs` — 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-multichain` 0.14.0, the QR-code MWP flow (desktop web and Node.js) omits the initial `wallet_createSession` request 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 +- The MWP transport modules (`@metamask/mobile-wallet-protocol-*`, `eciesjs`) are dynamically imported only when the MWP transport is actually used, so bundlers can code-split that crypto dependency tree out of the main chunk for browser-extension-only consumers +- **Do not statically import the MWP modules yourself in app code** — that defeats the code-split and re-inflates the bundle ## Permission Handling diff --git a/skills/metamask-connect/references/solana.md b/skills/metamask-connect/references/solana.md index 95a5cf8f..74d1e03c 100644 --- a/skills/metamask-connect/references/solana.md +++ b/skills/metamask-connect/references/solana.md @@ -10,7 +10,7 @@ Constraints for Solana integration: wallet-adapter config, CAIP-2 genesis-hash i - [RPC Routing](#rpc-routing) - [Disconnect Scopes Behavior](#disconnect-scopes-behavior) - [Chrome Android Bug](#chrome-android-bug) -- [React Native Limitation](#react-native-limitation) +- [React Native (no wallet-adapter)](#react-native-no-wallet-adapter) ## Wallet Adapter Configuration @@ -51,13 +51,12 @@ Constraints for Solana integration: wallet-adapter config, CAIP-2 genesis-hash i ## Chrome Android Bug -- There is a known issue with `@solana/wallet-adapter-react` on 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 +- `@solana/wallet-adapter-react`'s `WalletProvider` registers a `beforeunload` listener to detect window-unload disconnects. On Chrome for Android this misfires with MetaMask's wallet-standard provider (which doesn't emit a disconnect on unload), corrupting connection state +- This repo works around it with an **internal** yarn patch (`.yarn/patches/@solana-wallet-adapter-react-*.patch`) that removes that `beforeunload` effect — but the patch is **not shipped in `@metamask/connect-solana`**, so your app does not inherit it +- If you use `@solana/wallet-adapter-react` and target Chrome Android, apply the equivalent patch yourself (`yarn patch` / `patch-package`), or drive the wallet-standard provider directly without the React adapter +- Test Solana flows on desktop Chrome and the MetaMask browser extension before targeting mobile -## React Native Limitation +## React Native (no wallet-adapter) -- The Solana wallet adapter (`@solana/wallet-adapter-react`) is **not supported** in React Native -- For Solana in React Native, use the multichain client (`createMultichainClient`) with `invokeMethod` directly -- Do not attempt to import `@solana/wallet-adapter-react` or `@solana/wallet-adapter-react-ui` in RN — they depend on browser APIs +- `@solana/wallet-adapter-react` / `-react-ui` are browser-only (they depend on `window` and other DOM APIs), so the wallet-adapter-based Solana flow can't run in React Native — don't import them in an RN app +- This is a constraint of the third-party adapter, not of `@metamask/connect-solana`. For Solana in RN, skip the adapter and use the multichain client (`createMultichainClient`) with `invokeMethod` on CAIP-scoped Solana RPC methods (see [setup-solana-react-native.md](../workflows/setup-solana-react-native.md))