@wagmi/connectors
Version:
Collection of connectors for Wagmi
469 lines (436 loc) • 16.9 kB
text/typescript
import {
ChainNotConfiguredError,
type Connector,
ProviderNotFoundError,
createConnector,
extractRpcUrls,
} from '@wagmi/core'
import type { Compute, ExactPartial, Omit } from '@wagmi/core/internal'
import type { EthereumProvider } from '@walletconnect/ethereum-provider'
import {
type AddEthereumChainParameter,
type Address,
type ProviderConnectInfo,
type ProviderRpcError,
type RpcError,
SwitchChainError,
UserRejectedRequestError,
getAddress,
numberToHex,
} from 'viem'
type WalletConnectConnector = Connector & {
onDisplayUri(uri: string): void
onSessionDelete(data: { topic: string }): void
}
type EthereumProviderOptions = Parameters<(typeof EthereumProvider)['init']>[0]
export type WalletConnectParameters = Compute<
{
/**
* If a new chain is added to a previously existing configured connector `chains`, this flag
* will determine if that chain should be considered as stale. A stale chain is a chain that
* WalletConnect has yet to establish a relationship with (e.g. the user has not approved or
* rejected the chain).
*
* This flag mainly affects the behavior when a wallet does not support dynamic chain authorization
* with WalletConnect v2.
*
* If `true` (default), the new chain will be treated as a stale chain. If the user
* has yet to establish a relationship (approved/rejected) with this chain in their WalletConnect
* session, the connector will disconnect upon the dapp auto-connecting, and the user will have to
* reconnect to the dapp (revalidate the chain) in order to approve the newly added chain.
* This is the default behavior to avoid an unexpected error upon switching chains which may
* be a confusing user experience (e.g. the user will not know they have to reconnect
* unless the dapp handles these types of errors).
*
* If `false`, the new chain will be treated as a potentially valid chain. This means that if the user
* has yet to establish a relationship with the chain in their WalletConnect session, wagmi will successfully
* auto-connect the user. This comes with the trade-off that the connector will throw an error
* when attempting to switch to the unapproved chain if the wallet does not support dynamic session updates.
* This may be useful in cases where a dapp constantly
* modifies their configured chains, and they do not want to disconnect the user upon
* auto-connecting. If the user decides to switch to the unapproved chain, it is important that the
* dapp handles this error and prompts the user to reconnect to the dapp in order to approve
* the newly added chain.
*
* @default true
*/
isNewChainsStale?: boolean
} & Omit<
EthereumProviderOptions,
| 'chains'
| 'events'
| 'optionalChains'
| 'optionalEvents'
| 'optionalMethods'
| 'methods'
| 'rpcMap'
| 'showQrModal'
> &
ExactPartial<Pick<EthereumProviderOptions, 'showQrModal'>>
>
walletConnect.type = 'walletConnect' as const
export function walletConnect(parameters: WalletConnectParameters) {
const isNewChainsStale = parameters.isNewChainsStale ?? true
type Provider = Awaited<ReturnType<(typeof EthereumProvider)['init']>>
type Properties = {
connect(parameters?: {
chainId?: number | undefined
isReconnecting?: boolean | undefined
pairingTopic?: string | undefined
}): Promise<{
accounts: readonly Address[]
chainId: number
}>
getNamespaceChainsIds(): number[]
getRequestedChainsIds(): Promise<number[]>
isChainsStale(): Promise<boolean>
onConnect(connectInfo: ProviderConnectInfo): void
onDisplayUri(uri: string): void
onSessionDelete(data: { topic: string }): void
setRequestedChainsIds(chains: number[]): void
requestedChainsStorageKey: `${string}.requestedChains`
}
type StorageItem = {
[_ in Properties['requestedChainsStorageKey']]: number[]
}
let provider_: Provider | undefined
let providerPromise: Promise<typeof provider_>
const NAMESPACE = 'eip155'
let accountsChanged: WalletConnectConnector['onAccountsChanged'] | undefined
let chainChanged: WalletConnectConnector['onChainChanged'] | undefined
let connect: WalletConnectConnector['onConnect'] | undefined
let displayUri: WalletConnectConnector['onDisplayUri'] | undefined
let sessionDelete: WalletConnectConnector['onSessionDelete'] | undefined
let disconnect: WalletConnectConnector['onDisconnect'] | undefined
return createConnector<Provider, Properties, StorageItem>((config) => ({
id: 'walletConnect',
name: 'WalletConnect',
type: walletConnect.type,
async setup() {
const provider = await this.getProvider().catch(() => null)
if (!provider) return
if (!connect) {
connect = this.onConnect.bind(this)
provider.on('connect', connect)
}
if (!sessionDelete) {
sessionDelete = this.onSessionDelete.bind(this)
provider.on('session_delete', sessionDelete)
}
},
async connect({ chainId, ...rest } = {}) {
try {
const provider = await this.getProvider()
if (!provider) throw new ProviderNotFoundError()
if (!displayUri) {
displayUri = this.onDisplayUri
provider.on('display_uri', displayUri)
}
let targetChainId = chainId
if (!targetChainId) {
const state = (await config.storage?.getItem('state')) ?? {}
const isChainSupported = config.chains.some(
(x) => x.id === state.chainId,
)
if (isChainSupported) targetChainId = state.chainId
else targetChainId = config.chains[0]?.id
}
if (!targetChainId) throw new Error('No chains found on connector.')
const isChainsStale = await this.isChainsStale()
// If there is an active session with stale chains, disconnect current session.
if (provider.session && isChainsStale) await provider.disconnect()
// If there isn't an active session or chains are stale, connect.
if (!provider.session || isChainsStale) {
const optionalChains = config.chains
.filter((chain) => chain.id !== targetChainId)
.map((optionalChain) => optionalChain.id)
await provider.connect({
optionalChains: [targetChainId, ...optionalChains],
...('pairingTopic' in rest
? { pairingTopic: rest.pairingTopic }
: {}),
})
this.setRequestedChainsIds(config.chains.map((x) => x.id))
}
// If session exists and chains are authorized, enable provider for required chain
const accounts = (await provider.enable()).map((x) => getAddress(x))
const currentChainId = await this.getChainId()
if (displayUri) {
provider.removeListener('display_uri', displayUri)
displayUri = undefined
}
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)
}
if (!sessionDelete) {
sessionDelete = this.onSessionDelete.bind(this)
provider.on('session_delete', sessionDelete)
}
return { accounts, chainId: currentChainId }
} catch (error) {
if (
/(user rejected|connection request reset)/i.test(
(error as ProviderRpcError)?.message,
)
) {
throw new UserRejectedRequestError(error as Error)
}
throw error
}
},
async disconnect() {
const provider = await this.getProvider()
try {
await provider?.disconnect()
} catch (error) {
if (!/No matching key/i.test((error as Error).message)) throw error
} finally {
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)
}
if (accountsChanged) {
provider?.removeListener('accountsChanged', accountsChanged)
accountsChanged = undefined
}
if (sessionDelete) {
provider?.removeListener('session_delete', sessionDelete)
sessionDelete = undefined
}
this.setRequestedChainsIds([])
}
},
async getAccounts() {
const provider = await this.getProvider()
return provider.accounts.map((x) => getAddress(x))
},
async getProvider({ chainId } = {}) {
async function initProvider() {
const optionalChains = config.chains.map((x) => x.id) as [number]
if (!optionalChains.length) return
const { EthereumProvider } = await import(
'@walletconnect/ethereum-provider'
)
return await EthereumProvider.init({
...parameters,
disableProviderPing: true,
optionalChains,
projectId: parameters.projectId,
rpcMap: Object.fromEntries(
config.chains.map((chain) => {
const [url] = extractRpcUrls({
chain,
transports: config.transports,
})
return [chain.id, url]
}),
),
showQrModal: parameters.showQrModal ?? true,
})
}
if (!provider_) {
if (!providerPromise) providerPromise = initProvider()
provider_ = await providerPromise
provider_?.events.setMaxListeners(Number.POSITIVE_INFINITY)
}
if (chainId) await this.switchChain?.({ chainId })
return provider_!
},
async getChainId() {
const provider = await this.getProvider()
return provider.chainId
},
async isAuthorized() {
try {
const [accounts, provider] = await Promise.all([
this.getAccounts(),
this.getProvider(),
])
// If an account does not exist on the session, then the connector is unauthorized.
if (!accounts.length) return false
// If the chains are stale on the session, then the connector is unauthorized.
const isChainsStale = await this.isChainsStale()
if (isChainsStale && provider.session) {
await provider.disconnect().catch(() => {})
return false
}
return true
} 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())
try {
await Promise.all([
new Promise<void>((resolve) => {
const listener = ({
chainId: currentChainId,
}: { chainId?: number | undefined }) => {
if (currentChainId === chainId) {
config.emitter.off('change', listener)
resolve()
}
}
config.emitter.on('change', listener)
}),
provider.request({
method: 'wallet_switchEthereumChain',
params: [{ chainId: numberToHex(chainId) }],
}),
])
const requestedChains = await this.getRequestedChainsIds()
this.setRequestedChainsIds([...requestedChains, chainId])
return chain
} catch (err) {
const error = err as RpcError
if (/(user rejected)/i.test(error.message))
throw new UserRejectedRequestError(error)
// Indicates chain is not added to provider
try {
let blockExplorerUrls: string[] | undefined
if (addEthereumChainParameter?.blockExplorerUrls)
blockExplorerUrls = addEthereumChainParameter.blockExplorerUrls
else
blockExplorerUrls = chain.blockExplorers?.default.url
? [chain.blockExplorers?.default.url]
: []
let rpcUrls: readonly string[]
if (addEthereumChainParameter?.rpcUrls?.length)
rpcUrls = addEthereumChainParameter.rpcUrls
else rpcUrls = [...chain.rpcUrls.default.http]
const addEthereumChain = {
blockExplorerUrls,
chainId: numberToHex(chainId),
chainName: addEthereumChainParameter?.chainName ?? chain.name,
iconUrls: addEthereumChainParameter?.iconUrls,
nativeCurrency:
addEthereumChainParameter?.nativeCurrency ?? chain.nativeCurrency,
rpcUrls,
} satisfies AddEthereumChainParameter
await provider.request({
method: 'wallet_addEthereumChain',
params: [addEthereumChain],
})
const requestedChains = await this.getRequestedChainsIds()
this.setRequestedChainsIds([...requestedChains, chainId])
return chain
} catch (error) {
throw new UserRejectedRequestError(error as Error)
}
}
},
onAccountsChanged(accounts) {
if (accounts.length === 0) this.onDisconnect()
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 chainId = Number(connectInfo.chainId)
const accounts = await this.getAccounts()
config.emitter.emit('connect', { accounts, chainId })
},
async onDisconnect(_error) {
this.setRequestedChainsIds([])
config.emitter.emit('disconnect')
const provider = await this.getProvider()
if (accountsChanged) {
provider.removeListener('accountsChanged', accountsChanged)
accountsChanged = undefined
}
if (chainChanged) {
provider.removeListener('chainChanged', chainChanged)
chainChanged = undefined
}
if (disconnect) {
provider.removeListener('disconnect', disconnect)
disconnect = undefined
}
if (sessionDelete) {
provider.removeListener('session_delete', sessionDelete)
sessionDelete = undefined
}
if (!connect) {
connect = this.onConnect.bind(this)
provider.on('connect', connect)
}
},
onDisplayUri(uri) {
config.emitter.emit('message', { type: 'display_uri', data: uri })
},
onSessionDelete() {
this.onDisconnect()
},
getNamespaceChainsIds() {
if (!provider_) return []
const chainIds = provider_.session?.namespaces[NAMESPACE]?.accounts?.map(
(account) => Number.parseInt(account.split(':')[1] || ''),
)
return chainIds ?? []
},
async getRequestedChainsIds() {
return (
(await config.storage?.getItem(this.requestedChainsStorageKey)) ?? []
)
},
/**
* Checks if the target chains match the chains that were
* initially requested by the connector for the WalletConnect session.
* If there is a mismatch, this means that the chains on the connector
* are considered stale, and need to be revalidated at a later point (via
* connection).
*
* There may be a scenario where a dapp adds a chain to the
* connector later on, however, this chain will not have been approved or rejected
* by the wallet. In this case, the chain is considered stale.
*/
async isChainsStale() {
if (!isNewChainsStale) return false
const connectorChains = config.chains.map((x) => x.id)
const namespaceChains = this.getNamespaceChainsIds()
if (
namespaceChains.length &&
!namespaceChains.some((id) => connectorChains.includes(id))
)
return false
const requestedChains = await this.getRequestedChainsIds()
return !connectorChains.every((id) => requestedChains.includes(id))
},
async setRequestedChainsIds(chains) {
await config.storage?.setItem(this.requestedChainsStorageKey, chains)
},
get requestedChainsStorageKey() {
return `${this.id}.requestedChains` as Properties['requestedChainsStorageKey']
},
}))
}