Skip to main content
Glama
metamask.ts18.4 kB
import type { MetaMaskSDK, MetaMaskSDKOptions, RPC_URLS_MAP, SDKProvider, } from "@metamask/sdk"; import type { Connector } from "@wagmi/core"; import type { Compute, ExactPartial, OneOf, RemoveUndefined, UnionCompute, } from "@wagmi/core/internal"; import type { AddEthereumChainParameter, Address, Chain, Hex, ProviderConnectInfo, ProviderRpcError, RpcError } from "viem"; import { ChainNotConfiguredError, createConnector, extractRpcUrls, ProviderNotFoundError, } from "@wagmi/core"; import { defineChain, getAddress, hexToNumber, numberToHex, ResourceUnavailableRpcError, SwitchChainError, UserRejectedRequestError, withRetry, withTimeout, } from "viem"; export type MetaMaskParameters = UnionCompute< WagmiMetaMaskSDKOptions & OneOf< | { /* Shortcut to connect and sign a message */ connectAndSign?: string | undefined; } | { // TODO: Strongly type `method` and `params` /* Allow `connectWith` any rpc method */ connectWith?: { method: string; params: unknown[] } | undefined; } > >; type WagmiMetaMaskSDKOptions = Compute< ExactPartial< Omit< MetaMaskSDKOptions, | "_source" | "forceDeleteProvider" | "forceInjectProvider" | "injectProvider" | "useDeeplink" | "readonlyRPCMap" > > & { /** @deprecated */ forceDeleteProvider?: MetaMaskSDKOptions["forceDeleteProvider"]; /** @deprecated */ forceInjectProvider?: MetaMaskSDKOptions["forceInjectProvider"]; /** @deprecated */ injectProvider?: MetaMaskSDKOptions["injectProvider"]; /** @deprecated */ useDeeplink?: MetaMaskSDKOptions["useDeeplink"]; } >; interface Properties { [key: string]: unknown; onConnect: (connectInfo: ProviderConnectInfo) => void; onDisplayUri: (uri: string) => void; } metaMask.type = "metaMask" as const; export function metaMask(parameters: MetaMaskParameters = {}) { type Provider = SDKProvider; type Listener = Parameters<Provider["on"]>[1]; let sdk: MetaMaskSDK; let provider: Provider | undefined; let providerPromise: Promise<typeof provider>; let accountsChanged: Connector["onAccountsChanged"] | undefined; let chainChanged: Connector["onChainChanged"] | undefined; let connect: Connector["onConnect"] | undefined; let displayUri: ((uri: string) => void) | undefined; let disconnect: Connector["onDisconnect"] | undefined; return createConnector<Provider, Properties>(config => ({ id: "metaMaskSDK", name: "MetaMask", rdns: ["io.metamask", "io.metamask.mobile"], type: metaMask.type, async setup() { const provider = await this.getProvider(); if (provider?.on) { if (!connect) { connect = this.onConnect!.bind(this); provider.on("connect", connect as Listener); } // We shouldn't need to listen for `'accountsChanged'` here since the `'connect'` event should suffice (and wallet shouldn't be connected yet). // Some wallets, like MetaMask, do not implement the `'connect'` event and overload `'accountsChanged'` instead. if (!accountsChanged) { accountsChanged = this.onAccountsChanged.bind(this); provider.on("accountsChanged", accountsChanged as Listener); } } }, async connect({ chainId, isReconnecting } = {}) { const provider = await this.getProvider(); if (!displayUri) { displayUri = this.onDisplayUri; provider.on("display_uri", displayUri as Listener); } let accounts: readonly Address[] = []; if (isReconnecting) accounts = await this.getAccounts().catch(() => []); try { let signResponse: string | undefined; let connectWithResponse: unknown | undefined; if (!accounts?.length) { if (parameters.connectAndSign || parameters.connectWith) { if (parameters.connectAndSign) { signResponse = await sdk.connectAndSign({ msg: parameters.connectAndSign, }); } else if (parameters.connectWith) { connectWithResponse = await sdk.connectWith({ method: parameters.connectWith.method, params: parameters.connectWith.params, }); } accounts = await this.getAccounts(); } else { const requestedAccounts = (await sdk.connect()) as string[]; accounts = requestedAccounts.map(x => getAddress(x)); } } // Switch to chain if provided let currentChainId = (await this.getChainId()) as number; if (chainId && currentChainId !== chainId) { const chain = await this.switchChain!({ chainId }).catch((error) => { if (error.code === UserRejectedRequestError.code) throw error; return { id: currentChainId }; }); currentChainId = chain?.id ?? currentChainId; } if (displayUri) { provider.removeListener("display_uri", displayUri); displayUri = undefined; } if (signResponse) { provider.emit("connectAndSign", { accounts, chainId: currentChainId, signResponse, }); } else if (connectWithResponse) { provider.emit("connectWith", { accounts, chainId: currentChainId, connectWithResponse, }); } // Manage EIP-1193 event listeners // https://eips.ethereum.org/EIPS/eip-1193#events if (connect) { provider.removeListener("connect", connect); connect = undefined; } if (!accountsChanged) { accountsChanged = this.onAccountsChanged.bind(this); provider.on("accountsChanged", accountsChanged as Listener); } if (!chainChanged) { chainChanged = this.onChainChanged.bind(this); provider.on("chainChanged", chainChanged as Listener); } if (!disconnect) { disconnect = this.onDisconnect.bind(this); provider.on("disconnect", disconnect as Listener); } return { accounts, chainId: currentChainId }; } catch (err) { const error = err as RpcError; if (error.code === UserRejectedRequestError.code) throw new UserRejectedRequestError(error); if (error.code === ResourceUnavailableRpcError.code) throw new ResourceUnavailableRpcError(error); throw error; } }, async disconnect() { const provider = await this.getProvider(); // Manage EIP-1193 event listeners if (chainChanged) { provider.removeListener("chainChanged", chainChanged); chainChanged = undefined; } if (disconnect) { provider.removeListener("disconnect", disconnect); disconnect = undefined; } if (!connect) { connect = this.onConnect!.bind(this); provider.on("connect", connect as Listener); } await sdk.terminate(); }, async getAccounts() { const provider = await this.getProvider(); const accounts = (await provider.request({ method: "eth_accounts", })) as string[]; return accounts.map(x => getAddress(x)); }, async getChainId() { const provider = await this.getProvider(); const chainId = provider.getChainId() || (await provider?.request({ method: "eth_chainId" })); return Number(chainId); }, async getProvider() { async function initProvider() { const { MetaMaskSDK } = await import("@metamask/sdk"); const readonlyRPCMap: RPC_URLS_MAP = {}; for (const chain of config.chains) { readonlyRPCMap[numberToHex(chain.id)] = extractRpcUrls({ chain, transports: config.transports, })?.[0]; } sdk = new MetaMaskSDK({ _source: "wagmi", forceDeleteProvider: false, forceInjectProvider: false, injectProvider: false, // Workaround cast since MetaMask SDK does not support `'exactOptionalPropertyTypes'` ...(parameters as RemoveUndefined<typeof parameters>), readonlyRPCMap, dappMetadata: { ...parameters.dappMetadata, // Test if name and url are set AND not empty name: parameters.dappMetadata?.name ? parameters.dappMetadata?.name : "wagmi", url: parameters.dappMetadata?.url ? parameters.dappMetadata?.url : typeof window !== "undefined" ? window.location.origin : "https://wagmi.sh", }, useDeeplink: parameters.useDeeplink ?? true, }); const result = await sdk.init(); // On initial load, sometimes `sdk.getProvider` does not return provider. // https://github.com/wevm/wagmi/issues/4367 // Use result of `init` call if available. const provider = (() => { if (result?.activeProvider) return result.activeProvider; return sdk.getProvider(); })(); if (!provider) throw new ProviderNotFoundError(); return provider; } if (!provider) { if (!providerPromise) providerPromise = initProvider(); provider = await providerPromise; } return provider!; }, async isAuthorized() { try { // MetaMask mobile provider sometimes fails to immediately resolve // JSON-RPC requests on page load const timeout = 200; const accounts = await withRetry( () => withTimeout(() => this.getAccounts(), { timeout }), { delay: timeout + 1, retryCount: 3, }, ); return !!accounts.length; } catch { return false; } }, async switchChain({ addEthereumChainParameter, chainId }) { const provider = await this.getProvider(); let chain = config.chains.find(x => x.id === chainId); if (!chain) { if (addEthereumChainParameter) { chain = defineChain({ id: chainId, name: addEthereumChainParameter.chainName!, nativeCurrency: addEthereumChainParameter.nativeCurrency!, rpcUrls: { default: { http: addEthereumChainParameter.rpcUrls! }, }, blockExplorers: { default: { name: addEthereumChainParameter.chainName!, url: addEthereumChainParameter.blockExplorerUrls![0], }, }, }); } else { throw new SwitchChainError(new ChainNotConfiguredError()); } } try { await provider.request({ method: "wallet_switchEthereumChain", params: [{ chainId: numberToHex(chainId) }], }); // During `'wallet_switchEthereumChain'`, MetaMask makes a `'net_version'` RPC call to the target chain. // If this request fails, MetaMask does not emit the `'chainChanged'` event, but will still switch the chain. // To counter this behavior, we request and emit the current chain ID to confirm the chain switch either via // this callback or an externally emitted `'chainChanged'` event. // https://github.com/MetaMask/metamask-extension/issues/24247 await waitForChainIdToSync(); await sendAndWaitForChangeEvent(chainId); return chain; } catch (err) { const error = err as RpcError; if (error.code === UserRejectedRequestError.code) throw new UserRejectedRequestError(error); // Indicates chain is not added to provider if ( error.code === 4902 // Unwrapping for MetaMask Mobile // https://github.com/MetaMask/metamask-mobile/issues/2944#issuecomment-976988719 || (error as ProviderRpcError<{ originalError?: { code: number } }>) ?.data ?.originalError ?.code === 4902 ) { try { await provider.request({ method: "wallet_addEthereumChain", params: [ { blockExplorerUrls: (() => { const { default: blockExplorer, ...blockExplorers } = chain.blockExplorers ?? {}; if (addEthereumChainParameter?.blockExplorerUrls) return addEthereumChainParameter.blockExplorerUrls; if (blockExplorer) { return [ blockExplorer.url, ...Object.values(blockExplorers).map(x => x.url), ]; } })(), chainId: numberToHex(chainId), chainName: addEthereumChainParameter?.chainName ?? chain.name, iconUrls: addEthereumChainParameter?.iconUrls, nativeCurrency: addEthereumChainParameter?.nativeCurrency ?? chain.nativeCurrency, rpcUrls: (() => { if (addEthereumChainParameter?.rpcUrls?.length) return addEthereumChainParameter.rpcUrls; return [chain.rpcUrls.default?.http[0] ?? ""]; })(), } satisfies AddEthereumChainParameter, ], }); await waitForChainIdToSync(); await sendAndWaitForChangeEvent(chainId); return chain; } catch (err) { const error = err as RpcError; if (error.code === UserRejectedRequestError.code) throw new UserRejectedRequestError(error); throw new SwitchChainError(error); } } throw new SwitchChainError(error); } async function waitForChainIdToSync() { // On mobile, there is a race condition between the result of `'wallet_addEthereumChain'` and `'eth_chainId'`. // To avoid this, we wait for `'eth_chainId'` to return the expected chain ID with a retry loop. await withRetry( async () => { const value = hexToNumber( // `'eth_chainId'` is cached by the MetaMask SDK side to avoid unnecessary deeplinks (await provider.request({ method: "eth_chainId" })) as Hex, ); // `value` doesn't match expected `chainId`, throw to trigger retry if (value !== chainId) throw new Error("User rejected switch after adding network."); return value; }, { delay: 50, retryCount: 20, // android device encryption is slower }, ); } async function sendAndWaitForChangeEvent(chainId: number) { await new Promise<void>((resolve) => { const listener = ((data) => { if ("chainId" in data && data.chainId === chainId) { config.emitter.off("change", listener); resolve(); } }) satisfies Parameters<typeof config.emitter.on>[1]; config.emitter.on("change", listener); config.emitter.emit("change", { chainId }); }); } }, async onAccountsChanged(accounts) { // Disconnect if there are no accounts if (accounts.length === 0) { // ... and using browser extension if (sdk.isExtensionActive()) this.onDisconnect(); // FIXME(upstream): Mobile app sometimes emits invalid `accountsChanged` event with empty accounts array // eslint-disable-next-line no-useless-return else return; } // Connect if emitter is listening for connect event (e.g. is disconnected and connects through wallet interface) else if (config.emitter.listenerCount("connect")) { const chainId = (await this.getChainId()).toString(); this.onConnect!({ chainId }); } // Regular change event else { config.emitter.emit("change", { accounts: accounts.map(x => getAddress(x)), }); } }, onChainChanged(chain) { const chainId = Number(chain); config.emitter.emit("change", { chainId }); }, async onConnect(connectInfo) { const accounts = await this.getAccounts(); if (accounts.length === 0) return; const chainId = Number(connectInfo.chainId); config.emitter.emit("connect", { accounts, chainId }); const provider = await this.getProvider(); if (connect) { provider.removeListener("connect", connect); connect = undefined; } if (!accountsChanged) { accountsChanged = this.onAccountsChanged.bind(this); provider.on("accountsChanged", accountsChanged as Listener); } if (!chainChanged) { chainChanged = this.onChainChanged.bind(this); provider.on("chainChanged", chainChanged as Listener); } if (!disconnect) { disconnect = this.onDisconnect.bind(this); provider.on("disconnect", disconnect as Listener); } }, async onDisconnect(error) { const provider = await this.getProvider(); // If MetaMask emits a `code: 1013` error, wait for reconnection before disconnecting // https://github.com/MetaMask/providers/pull/120 if (error && (error as RpcError<1013>).code === 1013) { if (provider && !!(await this.getAccounts()).length) return; } config.emitter.emit("disconnect"); // Manage EIP-1193 event listeners if (chainChanged) { provider.removeListener("chainChanged", chainChanged); chainChanged = undefined; } if (disconnect) { provider.removeListener("disconnect", disconnect); disconnect = undefined; } if (!connect) { connect = this.onConnect!.bind(this); provider.on("connect", connect as Listener); } }, onDisplayUri(uri) { config.emitter.emit("message", { type: "display_uri", data: uri }); }, })); }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Xiawpohr/metamask-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server