Skip to content

Commit

Permalink
feat: refactor isChainsStale in WalletConnect Connector (#3924)
Browse files Browse the repository at this point in the history
* update ethereum-provider

* add-auth

* chore: added changeset

* remove-auth-changes

* revert-breaking-changes

* format

* update comments

* format, add codeowners for walletconnect connector

* format

* remove log

* switch promise order

* update-bun

* fix error code condition & update ethereum-provider

* remove error

* chore: changeset

---------

Co-authored-by: Glitch <glitch.txs@gmail.com>
Co-authored-by: ignaciosantise <nacho@walletconnect.com>
  • Loading branch information
3 people committed May 13, 2024
1 parent cd2f228 commit 1f58734
Show file tree
Hide file tree
Showing 5 changed files with 144 additions and 86 deletions.
7 changes: 7 additions & 0 deletions .changeset/giant-insects-juggle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@wagmi/connectors": patch
"@wagmi/core": patch
"wagmi": patch
---

Refactored `isChainsStale` logic in `walletConnect` connector.
4 changes: 4 additions & 0 deletions .changeset/late-fireants-turn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
"@wagmi/connectors": minor
---
Refactored isNewChainsStale, Updated @walletconnect/ethereum-provider
2 changes: 1 addition & 1 deletion .github/CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
@tmm @jxom

/packages/connectors/src/safe @DaniSomoza @dasanra @mikhailxyz @yagopv
/packages/connectors/src/walletConnect @ganchoradkov @0xAsimetriq
/packages/connectors/src/walletConnect @ganchoradkov @glitch-txs @ignaciosantise @tomiir
5 changes: 1 addition & 4 deletions packages/connectors/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,7 @@
"@wagmi/core": "workspace:*",
"msw": "^2.2.14"
},
"contributors": [
"awkweb.eth <t@wevm.dev>",
"jxom.eth <j@wevm.dev>"
],
"contributors": ["awkweb.eth <t@wevm.dev>", "jxom.eth <j@wevm.dev>"],
"funding": "https://github.com/sponsors/wevm",
"keywords": [
"react",
Expand Down
212 changes: 131 additions & 81 deletions packages/connectors/src/walletConnect.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
ChainNotConfiguredError,
type Connector,
ProviderNotFoundError,
createConnector,
} from '@wagmi/core'
Expand All @@ -8,18 +9,24 @@ import {
type ExactPartial,
type Omit,
} from '@wagmi/core/internal'
import type { EthereumProvider } from '@walletconnect/ethereum-provider'
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 = Evaluate<
Expand All @@ -30,10 +37,6 @@ export type WalletConnectParameters = Evaluate<
* WalletConnect has yet to establish a relationship with (e.g. the user has not approved or
* rejected the chain).
*
* Preface: Whereas WalletConnect v1 supported dynamic chain switching, WalletConnect v2 requires
* the user to pre-approve a set of chains up-front. This comes with consequent UX nuances (see below) when
* a user tries to switch to a chain that they have not approved.
*
* This flag mainly affects the behavior when a wallet does not support dynamic chain authorization
* with WalletConnect v2.
*
Expand All @@ -45,10 +48,11 @@ export type WalletConnectParameters = Evaluate<
* 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 validated chain. This means that if the user
* 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. This may be useful in cases where a dapp constantly
* 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
Expand Down Expand Up @@ -76,16 +80,12 @@ export function walletConnect(parameters: WalletConnectParameters) {
const isNewChainsStale = parameters.isNewChainsStale ?? true

type Provider = Awaited<ReturnType<typeof EthereumProvider['init']>>
type NamespaceMethods =
| 'wallet_addEthereumChain'
| 'wallet_switchEthereumChain'
type Properties = {
connect(parameters?: { chainId?: number; pairingTopic?: string }): Promise<{
accounts: readonly Address[]
chainId: number
}>
getNamespaceChainsIds(): number[]
getNamespaceMethods(): NamespaceMethods[]
getRequestedChainsIds(): Promise<number[]>
isChainsStale(): Promise<boolean>
onConnect(connectInfo: ProviderConnectInfo): void
Expand All @@ -102,21 +102,37 @@ export function walletConnect(parameters: WalletConnectParameters) {
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
provider.on('connect', this.onConnect.bind(this))
provider.on('session_delete', this.onSessionDelete.bind(this))
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()
provider.on('display_uri', this.onDisplayUri)
if (!displayUri) {
displayUri = this.onDisplayUri
provider.on('display_uri', displayUri)
}

let targetChainId = chainId
if (!targetChainId) {
Expand Down Expand Up @@ -152,12 +168,30 @@ export function walletConnect(parameters: WalletConnectParameters) {
const accounts = (await provider.enable()).map((x) => getAddress(x))
const currentChainId = await this.getChainId()

provider.removeListener('display_uri', this.onDisplayUri)
provider.removeListener('connect', this.onConnect.bind(this))
provider.on('accountsChanged', this.onAccountsChanged.bind(this))
provider.on('chainChanged', this.onChainChanged)
provider.on('disconnect', this.onDisconnect.bind(this))
provider.on('session_delete', this.onSessionDelete.bind(this))
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) {
Expand All @@ -178,17 +212,26 @@ export function walletConnect(parameters: WalletConnectParameters) {
} catch (error) {
if (!/No matching key/i.test((error as Error).message)) throw error
} finally {
provider?.removeListener(
'accountsChanged',
this.onAccountsChanged.bind(this),
)
provider?.removeListener('chainChanged', this.onChainChanged)
provider?.removeListener('disconnect', this.onDisconnect.bind(this))
provider?.removeListener(
'session_delete',
this.onSessionDelete.bind(this),
)
provider?.on('connect', this.onConnect.bind(this))
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([])
}
Expand Down Expand Up @@ -253,19 +296,43 @@ export function walletConnect(parameters: WalletConnectParameters) {
}
},
async switchChain({ addEthereumChainParameter, chainId }) {
const chain = config.chains.find((chain) => chain.id === 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 {
const provider = await this.getProvider()
const namespaceChains = this.getNamespaceChainsIds()
const namespaceMethods = this.getNamespaceMethods()
const isChainApproved = namespaceChains.includes(chainId)
await Promise.all([
new Promise<void>((resolve) => {
const listener = ({
chainId: currentChainId,
}: { chainId?: number }) => {
if (currentChainId === chainId) {
config.emitter.off('change', listener)
resolve()
}
}
config.emitter.on('change', listener)
}),
provider.request({
method: 'wallet_switchEthereumChain',
params: [{ chainId: numberToHex(chainId) }],
}),
])

if (
!isChainApproved &&
namespaceMethods.includes('wallet_addEthereumChain')
) {
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
if (addEthereumChainParameter?.blockExplorerUrls)
blockExplorerUrls = addEthereumChainParameter.blockExplorerUrls
Expand Down Expand Up @@ -293,23 +360,13 @@ export function walletConnect(parameters: WalletConnectParameters) {
method: 'wallet_addEthereumChain',
params: [addEthereumChain],
})

const requestedChains = await this.getRequestedChainsIds()
this.setRequestedChainsIds([...requestedChains, chainId])
}

await provider.request({
method: 'wallet_switchEthereumChain',
params: [{ chainId: numberToHex(chainId) }],
})
return chain
} catch (error) {
const message =
typeof error === 'string'
? error
: (error as ProviderRpcError)?.message
if (/user rejected request/i.test(message))
return chain
} catch (error) {
throw new UserRejectedRequestError(error as Error)
throw new SwitchChainError(error as Error)
}
}
},
onAccountsChanged(accounts) {
Expand All @@ -333,14 +390,26 @@ export function walletConnect(parameters: WalletConnectParameters) {
config.emitter.emit('disconnect')

const provider = await this.getProvider()
provider.removeListener(
'accountsChanged',
this.onAccountsChanged.bind(this),
)
provider.removeListener('chainChanged', this.onChainChanged)
provider.removeListener('disconnect', this.onDisconnect.bind(this))
provider.removeListener('session_delete', this.onSessionDelete.bind(this))
provider.on('connect', this.onConnect.bind(this))
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 })
Expand All @@ -355,12 +424,6 @@ export function walletConnect(parameters: WalletConnectParameters) {
)
return chainIds ?? []
},
getNamespaceMethods() {
if (!provider_) return []
const methods = provider_.session?.namespaces[NAMESPACE]
?.methods as NamespaceMethods[]
return methods ?? []
},
async getRequestedChainsIds() {
return (
(await config.storage?.getItem(this.requestedChainsStorageKey)) ?? []
Expand All @@ -376,21 +439,8 @@ export function walletConnect(parameters: WalletConnectParameters) {
* 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.
*
* There are exceptions however:
* - If the wallet supports dynamic chain addition via `eth_addEthereumChain`,
* then the chain is not considered stale.
* - If the `isNewChainsStale` flag is falsy on the connector, then the chain is
* not considered stale.
*
* For the above cases, chain validation occurs dynamically when the user
* attempts to switch chain.
*
* Also check that dapp supports at least 1 chain from previously approved session.
*/
async isChainsStale() {
const namespaceMethods = this.getNamespaceMethods()
if (namespaceMethods.includes('wallet_addEthereumChain')) return false
if (!isNewChainsStale) return false

const connectorChains = config.chains.map((x) => x.id)
Expand Down

0 comments on commit 1f58734

Please sign in to comment.