@wagmi/connectors
Version:
Collection of connectors for Wagmi
506 lines (470 loc) • 17.6 kB
text/typescript
import type {
MetaMaskSDK,
MetaMaskSDKOptions,
RPC_URLS_MAP,
SDKProvider,
} from '@metamask/sdk'
import {
ChainNotConfiguredError,
type Connector,
ProviderNotFoundError,
createConnector,
extractRpcUrls,
} from '@wagmi/core'
import type {
Compute,
ExactPartial,
OneOf,
RemoveUndefined,
UnionCompute,
} from '@wagmi/core/internal'
import {
type AddEthereumChainParameter,
type Address,
type Hex,
type ProviderConnectInfo,
type ProviderRpcError,
ResourceUnavailableRpcError,
type RpcError,
SwitchChainError,
UserRejectedRequestError,
getAddress,
hexToNumber,
numberToHex,
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']
}
>
metaMask.type = 'metaMask' as const
export function metaMask(parameters: MetaMaskParameters = {}) {
type Provider = SDKProvider
type Properties = {
onConnect(connectInfo: ProviderConnectInfo): void
onDisplayUri(uri: string): void
}
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() {
// Unwrapping import for Vite compatibility.
// See: https://github.com/vitejs/vite/issues/9703
const MetaMaskSDK = await (async () => {
const { default: SDK } = await import('@metamask/sdk')
if (typeof SDK !== 'function' && typeof SDK.default === 'function')
return SDK.default
return SDK as unknown as typeof SDK.default
})()
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()
const chain = config.chains.find((x) => x.id === chainId)
if (!chain) 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),
]
return
})(),
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
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 })
},
}))
}