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 });
},
}));
}