forked from wevm/wagmi
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathwalletConnect.ts
More file actions
500 lines (467 loc) · 17.8 KB
/
walletConnect.ts
File metadata and controls
500 lines (467 loc) · 17.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
import {
ChainNotConfiguredError,
type Connector,
createConnector,
extractRpcUrls,
ProviderNotFoundError,
} from '@wagmi/core'
import type { Compute, ExactPartial, Omit } from '@wagmi/core/internal'
import type { EthereumProvider } from '@walletconnect/ethereum-provider'
import {
type AddEthereumChainParameter,
type Address,
getAddress,
numberToHex,
type ProviderConnectInfo,
type ProviderRpcError,
type RpcError,
SwitchChainError,
UserRejectedRequestError,
} 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 = {
// TODO(v3): Make `withCapabilities: true` default behavior
connect<withCapabilities extends boolean = false>(parameters?: {
chainId?: number | undefined
isReconnecting?: boolean | undefined
pairingTopic?: string | undefined
withCapabilities?: withCapabilities | boolean | undefined
}): Promise<{
accounts: withCapabilities extends true
? readonly { address: Address }[]
: 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, withCapabilities, ...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))
// Switch to chain if provided
let currentChainId = await this.getChainId()
if (chainId && currentChainId !== chainId) {
const chain = await this.switchChain!({ chainId }).catch(
(error: RpcError) => {
if (
error.code === UserRejectedRequestError.code &&
(error.cause as RpcError | undefined)?.message !==
'Missing or invalid. request() method: wallet_addEthereumChain'
)
throw error
return { id: currentChainId }
},
)
currentChainId = chain?.id ?? currentChainId
}
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: (withCapabilities
? accounts.map((address) => ({ address, capabilities: {} }))
: accounts) as never,
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 (() => {
// safe webpack optional peer dependency dynamic import
try {
return import('@walletconnect/ethereum-provider')
} catch {
throw new Error(
'dependency "@walletconnect/ethereum-provider" not found',
)
}
})()
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())
let listener: (opts: { chainId?: number | undefined }) => void = () => {}
try {
await Promise.all([
new Promise<void>((resolve) => {
listener = (opts) => {
if (opts.chainId === 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) {
config.emitter.off('change', listener)
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] || '', 10),
)
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']
},
}))
}