@wagmi/core
Version:
VanillaJS library for Ethereum
698 lines (645 loc) • 24.1 kB
text/typescript
import {
type AddEthereumChainParameter,
type Address,
type EIP1193Provider,
type ProviderConnectInfo,
type ProviderRpcError,
ResourceUnavailableRpcError,
type RpcError,
SwitchChainError,
UserRejectedRequestError,
getAddress,
numberToHex,
withRetry,
withTimeout,
} from 'viem'
import type { Connector } from '../createConfig.js'
import { ChainNotConfiguredError } from '../errors/config.js'
import { ProviderNotFoundError } from '../errors/connector.js'
import type { Compute } from '../types/utils.js'
import { createConnector } from './createConnector.js'
export type InjectedParameters = {
/**
* Some injected providers do not support programmatic disconnect.
* This flag simulates the disconnect behavior by keeping track of connection status in storage.
* @default true
*/
shimDisconnect?: boolean | undefined
/**
* [EIP-1193](https://eips.ethereum.org/EIPS/eip-1193) Ethereum Provider to target
*/
target?: TargetId | Target | (() => Target | undefined) | undefined
unstable_shimAsyncInject?: boolean | number | undefined
}
injected.type = 'injected' as const
export function injected(parameters: InjectedParameters = {}) {
const { shimDisconnect = true, unstable_shimAsyncInject } = parameters
function getTarget(): Compute<Target & { id: string }> {
const target = parameters.target
if (typeof target === 'function') {
const result = target()
if (result) return result
}
if (typeof target === 'object') return target
if (typeof target === 'string')
return {
...(targetMap[target as keyof typeof targetMap] ?? {
id: target,
name: `${target[0]!.toUpperCase()}${target.slice(1)}`,
provider: `is${target[0]!.toUpperCase()}${target.slice(1)}`,
}),
}
return {
id: 'injected',
name: 'Injected',
provider(window) {
return window?.ethereum
},
}
}
type Provider = WalletProvider | undefined
type Properties = {
onConnect(connectInfo: ProviderConnectInfo): void
}
type StorageItem = {
[_ in 'injected.connected' | `${string}.disconnected`]: true
}
let accountsChanged: Connector['onAccountsChanged'] | undefined
let chainChanged: Connector['onChainChanged'] | undefined
let connect: Connector['onConnect'] | undefined
let disconnect: Connector['onDisconnect'] | undefined
return createConnector<Provider, Properties, StorageItem>((config) => ({
get icon() {
return getTarget().icon
},
get id() {
return getTarget().id
},
get name() {
return getTarget().name
},
/** @deprecated */
get supportsSimulation() {
return true
},
type: injected.type,
async setup() {
const provider = await this.getProvider()
// Only start listening for events if `target` is set, otherwise `injected()` will also receive events
if (provider?.on && parameters.target) {
if (!connect) {
connect = this.onConnect.bind(this)
provider.on('connect', connect)
}
// 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)
}
}
},
async connect({ chainId, isReconnecting } = {}) {
const provider = await this.getProvider()
if (!provider) throw new ProviderNotFoundError()
let accounts: readonly Address[] = []
if (isReconnecting) accounts = await this.getAccounts().catch(() => [])
else if (shimDisconnect) {
// Attempt to show another prompt for selecting account if `shimDisconnect` flag is enabled
try {
const permissions = await provider.request({
method: 'wallet_requestPermissions',
params: [{ eth_accounts: {} }],
})
accounts = (permissions[0]?.caveats?.[0]?.value as string[])?.map(
(x) => getAddress(x),
)
// `'wallet_requestPermissions'` can return a different order of accounts than `'eth_accounts'`
// switch to `'eth_accounts'` ordering if more than one account is connected
// https://github.com/wevm/wagmi/issues/4140
if (accounts.length > 0) {
const sortedAccounts = await this.getAccounts()
accounts = sortedAccounts
}
} catch (err) {
const error = err as RpcError
// Not all injected providers support `wallet_requestPermissions` (e.g. MetaMask iOS).
// Only bubble up error if user rejects request
if (error.code === UserRejectedRequestError.code)
throw new UserRejectedRequestError(error)
// Or prompt is already open
if (error.code === ResourceUnavailableRpcError.code) throw error
}
}
try {
if (!accounts?.length && !isReconnecting) {
const requestedAccounts = await provider.request({
method: 'eth_requestAccounts',
})
accounts = requestedAccounts.map((x) => getAddress(x))
}
// 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)
}
if (!chainChanged) {
chainChanged = this.onChainChanged.bind(this)
provider.on('chainChanged', chainChanged)
}
if (!disconnect) {
disconnect = this.onDisconnect.bind(this)
provider.on('disconnect', disconnect)
}
// Switch to chain if provided
let currentChainId = await this.getChainId()
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
}
// Remove disconnected shim if it exists
if (shimDisconnect)
await config.storage?.removeItem(`${this.id}.disconnected`)
// Add connected shim if no target exists
if (!parameters.target)
await config.storage?.setItem('injected.connected', true)
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()
if (!provider) throw new ProviderNotFoundError()
// 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)
}
// Experimental support for MetaMask disconnect
// https://github.com/MetaMask/metamask-improvement-proposals/blob/main/MIPs/mip-2.md
try {
// Adding timeout as not all wallets support this method and can hang
// https://github.com/wevm/wagmi/issues/4064
await withTimeout(
() =>
// TODO: Remove explicit type for viem@3
provider.request<{
Method: 'wallet_revokePermissions'
Parameters: [permissions: { eth_accounts: Record<string, any> }]
ReturnType: null
}>({
// `'wallet_revokePermissions'` added in `viem@2.10.3`
method: 'wallet_revokePermissions',
params: [{ eth_accounts: {} }],
}),
{ timeout: 100 },
)
} catch {}
// Add shim signalling connector is disconnected
if (shimDisconnect) {
await config.storage?.setItem(`${this.id}.disconnected`, true)
}
if (!parameters.target)
await config.storage?.removeItem('injected.connected')
},
async getAccounts() {
const provider = await this.getProvider()
if (!provider) throw new ProviderNotFoundError()
const accounts = await provider.request({ method: 'eth_accounts' })
return accounts.map((x) => getAddress(x))
},
async getChainId() {
const provider = await this.getProvider()
if (!provider) throw new ProviderNotFoundError()
const hexChainId = await provider.request({ method: 'eth_chainId' })
return Number(hexChainId)
},
async getProvider() {
if (typeof window === 'undefined') return undefined
let provider: Provider
const target = getTarget()
if (typeof target.provider === 'function')
provider = target.provider(window as Window | undefined)
else if (typeof target.provider === 'string')
provider = findProvider(window, target.provider)
else provider = target.provider
// Some wallets do not conform to EIP-1193 (e.g. Trust Wallet)
// https://github.com/wevm/wagmi/issues/3526#issuecomment-1912683002
if (provider && !provider.removeListener) {
// Try using `off` handler if it exists, otherwise noop
if ('off' in provider && typeof provider.off === 'function')
provider.removeListener =
provider.off as typeof provider.removeListener
else provider.removeListener = () => {}
}
return provider
},
async isAuthorized() {
try {
const isDisconnected =
shimDisconnect &&
// If shim exists in storage, connector is disconnected
(await config.storage?.getItem(`${this.id}.disconnected`))
if (isDisconnected) return false
// Don't allow injected connector to connect if no target is set and it hasn't already connected
// (e.g. flag in storage is not set). This prevents a targetless injected connector from connecting
// automatically whenever there is a targeted connector configured.
if (!parameters.target) {
const connected = await config.storage?.getItem('injected.connected')
if (!connected) return false
}
const provider = await this.getProvider()
if (!provider) {
if (
unstable_shimAsyncInject !== undefined &&
unstable_shimAsyncInject !== false
) {
// If no provider is found, check for async injection
// https://github.com/wevm/references/issues/167
// https://github.com/MetaMask/detect-provider
const handleEthereum = async () => {
if (typeof window !== 'undefined')
window.removeEventListener(
'ethereum#initialized',
handleEthereum,
)
const provider = await this.getProvider()
return !!provider
}
const timeout =
typeof unstable_shimAsyncInject === 'number'
? unstable_shimAsyncInject
: 1_000
const res = await Promise.race([
...(typeof window !== 'undefined'
? [
new Promise<boolean>((resolve) =>
window.addEventListener(
'ethereum#initialized',
() => resolve(handleEthereum()),
{ once: true },
),
),
]
: []),
new Promise<boolean>((resolve) =>
setTimeout(() => resolve(handleEthereum()), timeout),
),
])
if (res) return true
}
throw new ProviderNotFoundError()
}
// Use retry strategy as some injected wallets (e.g. MetaMask) fail to
// immediately resolve JSON-RPC requests on page load.
const accounts = await withRetry(() => this.getAccounts())
return !!accounts.length
} catch {
return false
}
},
async switchChain({ addEthereumChainParameter, chainId }) {
const provider = await this.getProvider()
if (!provider) throw new ProviderNotFoundError()
const chain = config.chains.find((x) => x.id === chainId)
if (!chain) throw new SwitchChainError(new ChainNotConfiguredError())
const promise = 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)
})
try {
await Promise.all([
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
.then(async () => {
const currentChainId = await this.getChainId()
if (currentChainId === chainId)
config.emitter.emit('change', { chainId })
}),
promise,
])
return chain
} catch (err) {
const error = err as RpcError
// 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 {
const { default: blockExplorer, ...blockExplorers } =
chain.blockExplorers ?? {}
let blockExplorerUrls: string[] | undefined
if (addEthereumChainParameter?.blockExplorerUrls)
blockExplorerUrls = addEthereumChainParameter.blockExplorerUrls
else if (blockExplorer)
blockExplorerUrls = [
blockExplorer.url,
...Object.values(blockExplorers).map((x) => x.url),
]
let rpcUrls: readonly string[]
if (addEthereumChainParameter?.rpcUrls?.length)
rpcUrls = addEthereumChainParameter.rpcUrls
else rpcUrls = [chain.rpcUrls.default?.http[0] ?? '']
const addEthereumChain = {
blockExplorerUrls,
chainId: numberToHex(chainId),
chainName: addEthereumChainParameter?.chainName ?? chain.name,
iconUrls: addEthereumChainParameter?.iconUrls,
nativeCurrency:
addEthereumChainParameter?.nativeCurrency ??
chain.nativeCurrency,
rpcUrls,
} satisfies AddEthereumChainParameter
await Promise.all([
provider
.request({
method: 'wallet_addEthereumChain',
params: [addEthereumChain],
})
.then(async () => {
const currentChainId = await this.getChainId()
if (currentChainId === chainId)
config.emitter.emit('change', { chainId })
else
throw new UserRejectedRequestError(
new Error('User rejected switch after adding network.'),
)
}),
promise,
])
return chain
} catch (error) {
throw new UserRejectedRequestError(error as Error)
}
}
if (error.code === UserRejectedRequestError.code)
throw new UserRejectedRequestError(error)
throw new SwitchChainError(error)
}
},
async onAccountsChanged(accounts) {
// Disconnect if there are no accounts
if (accounts.length === 0) this.onDisconnect()
// 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 })
// Remove disconnected shim if it exists
if (shimDisconnect)
await config.storage?.removeItem(`${this.id}.disconnected`)
}
// 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 })
// Manage EIP-1193 event listeners
const provider = await this.getProvider()
if (provider) {
if (connect) {
provider.removeListener('connect', connect)
connect = undefined
}
if (!accountsChanged) {
accountsChanged = this.onAccountsChanged.bind(this)
provider.on('accountsChanged', accountsChanged)
}
if (!chainChanged) {
chainChanged = this.onChainChanged.bind(this)
provider.on('chainChanged', chainChanged)
}
if (!disconnect) {
disconnect = this.onDisconnect.bind(this)
provider.on('disconnect', disconnect)
}
}
},
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
}
// No need to remove `${this.id}.disconnected` from storage because `onDisconnect` is typically
// only called when the wallet is disconnected through the wallet's interface, meaning the wallet
// actually disconnected and we don't need to simulate it.
config.emitter.emit('disconnect')
// Manage EIP-1193 event listeners
if (provider) {
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)
}
}
},
}))
}
const targetMap = {
coinbaseWallet: {
id: 'coinbaseWallet',
name: 'Coinbase Wallet',
provider(window) {
if (window?.coinbaseWalletExtension) return window.coinbaseWalletExtension
return findProvider(window, 'isCoinbaseWallet')
},
},
metaMask: {
id: 'metaMask',
name: 'MetaMask',
provider(window) {
return findProvider(window, (provider) => {
if (!provider.isMetaMask) return false
// Brave tries to make itself look like MetaMask
// Could also try RPC `web3_clientVersion` if following is unreliable
if (provider.isBraveWallet && !provider._events && !provider._state)
return false
// Other wallets that try to look like MetaMask
const flags = [
'isApexWallet',
'isAvalanche',
'isBitKeep',
'isBlockWallet',
'isKuCoinWallet',
'isMathWallet',
'isOkxWallet',
'isOKExWallet',
'isOneInchIOSWallet',
'isOneInchAndroidWallet',
'isOpera',
'isPhantom',
'isPortal',
'isRabby',
'isTokenPocket',
'isTokenary',
'isUniswapWallet',
'isZerion',
] satisfies WalletProviderFlags[]
for (const flag of flags) if (provider[flag]) return false
return true
})
},
},
phantom: {
id: 'phantom',
name: 'Phantom',
provider(window) {
if (window?.phantom?.ethereum) return window.phantom?.ethereum
return findProvider(window, 'isPhantom')
},
},
} as const satisfies TargetMap
type TargetMap = { [_ in TargetId]?: Target | undefined }
type Target = {
icon?: string | undefined
id: string
name: string
provider:
| WalletProviderFlags
| WalletProvider
| ((window?: Window | undefined) => WalletProvider | undefined)
}
/** @deprecated */
type TargetId = Compute<WalletProviderFlags> extends `is${infer name}`
? name extends `${infer char}${infer rest}`
? `${Lowercase<char>}${rest}`
: never
: never
/**
* @deprecated As of 2024/10/16, we are no longer accepting new provider flags as EIP-6963 should be used instead.
*/
type WalletProviderFlags =
| 'isApexWallet'
| 'isAvalanche'
| 'isBackpack'
| 'isBifrost'
| 'isBitKeep'
| 'isBitski'
| 'isBlockWallet'
| 'isBraveWallet'
| 'isCoinbaseWallet'
| 'isDawn'
| 'isEnkrypt'
| 'isExodus'
| 'isFrame'
| 'isFrontier'
| 'isGamestop'
| 'isHyperPay'
| 'isImToken'
| 'isKuCoinWallet'
| 'isMathWallet'
| 'isMetaMask'
| 'isOkxWallet'
| 'isOKExWallet'
| 'isOneInchAndroidWallet'
| 'isOneInchIOSWallet'
| 'isOpera'
| 'isPhantom'
| 'isPortal'
| 'isRabby'
| 'isRainbow'
| 'isStatus'
| 'isTally'
| 'isTokenPocket'
| 'isTokenary'
| 'isTrust'
| 'isTrustWallet'
| 'isUniswapWallet'
| 'isXDEFI'
| 'isZerion'
type WalletProvider = Compute<
EIP1193Provider & {
[key in WalletProviderFlags]?: true | undefined
} & {
providers?: WalletProvider[] | undefined
/** Only exists in MetaMask as of 2022/04/03 */
_events?: { connect?: (() => void) | undefined } | undefined
/** Only exists in MetaMask as of 2022/04/03 */
_state?:
| {
accounts?: string[]
initialized?: boolean
isConnected?: boolean
isPermanentlyDisconnected?: boolean
isUnlocked?: boolean
}
| undefined
}
>
type Window = {
coinbaseWalletExtension?: WalletProvider | undefined
ethereum?: WalletProvider | undefined
phantom?: { ethereum: WalletProvider } | undefined
}
function findProvider(
window: globalThis.Window | Window | undefined,
select?: WalletProviderFlags | ((provider: WalletProvider) => boolean),
) {
function isProvider(provider: WalletProvider) {
if (typeof select === 'function') return select(provider)
if (typeof select === 'string') return provider[select]
return true
}
const ethereum = (window as Window).ethereum
if (ethereum?.providers)
return ethereum.providers.find((provider) => isProvider(provider))
if (ethereum && isProvider(ethereum)) return ethereum
return undefined
}