Skip to content

Commit

Permalink
feat: add support for optionalChains in WalletConnect V2 (#805)
Browse files Browse the repository at this point in the history
* feat: initial

* fixes

* fix: wording

* tweaks

* add todo

* add example

* chore: update wc2 and remove todo

* feat: update unit tests

* Update packages/walletconnect-v2/src/index.ts

Co-authored-by: Zach Pomerantz <[email protected]>

* Update packages/walletconnect-v2/src/utils.ts

Co-authored-by: Zach Pomerantz <[email protected]>

* Update packages/walletconnect-v2/src/index.ts

Co-authored-by: Zach Pomerantz <[email protected]>

* Update packages/walletconnect-v2/src/index.ts

Co-authored-by: Zach Pomerantz <[email protected]>

* Update packages/walletconnect-v2/src/index.ts

Co-authored-by: Zach Pomerantz <[email protected]>

---------

Co-authored-by: Zach Pomerantz <[email protected]>
  • Loading branch information
grabbou and zzmp authored Apr 26, 2023
1 parent 5621b74 commit d0fafce
Show file tree
Hide file tree
Showing 7 changed files with 268 additions and 501 deletions.
6 changes: 5 additions & 1 deletion example/connectors/walletConnectV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,17 @@ import { WalletConnect as WalletConnectV2 } from '@web3-react/walletconnect-v2'

import { MAINNET_CHAINS } from '../chains'

const [mainnet, ...optionalChains] = Object.keys(MAINNET_CHAINS).map(Number)

export const [walletConnectV2, hooks] = initializeConnector<WalletConnectV2>(
(actions) =>
new WalletConnectV2({
actions,
options: {
projectId: process.env.walletConnectProjectId,
chains: Object.keys(MAINNET_CHAINS).map(Number),
chains: [mainnet],
optionalChains,
showQrModal: true,
},
})
)
2 changes: 1 addition & 1 deletion packages/walletconnect-v2/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"start": "tsc --watch"
},
"dependencies": {
"@walletconnect/ethereum-provider": "^2.5.1",
"@walletconnect/ethereum-provider": "^2.7.0",
"@web3-react/types": "^8.2.0",
"@web3modal/standalone": "^2.2.1",
"eventemitter3": "^4.0.7"
Expand Down
151 changes: 110 additions & 41 deletions packages/walletconnect-v2/src/index.spec.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,33 @@
// Not avaiable in the `node` environment, but required by WalletConnect
// Not available in the `node` environment, but required by WalletConnect
global.TextEncoder = jest.fn()
global.TextDecoder = jest.fn()

import { createWeb3ReactStoreAndActions } from '@web3-react/store'
import { EthereumProvider } from '@walletconnect/ethereum-provider'
import { MockEIP1193Provider } from '@web3-react/core'
import { createWeb3ReactStoreAndActions } from '@web3-react/store'
import { RequestArguments } from '@web3-react/types'
import { EthereumProvider } from '@walletconnect/ethereum-provider'

import { WalletConnect, WalletConnectOptions } from '.'

const createTestEnvironment = (opts: Omit<WalletConnectOptions, 'projectId'>) => {
type EthereumProviderOptions = Parameters<typeof EthereumProvider.init>[0]

const createTestEnvironment = (
opts: Omit<WalletConnectOptions, 'projectId' | 'showQrModal'>,
defaultChainId?: number
) => {
const [store, actions] = createWeb3ReactStoreAndActions()
const connector = new WalletConnect({ actions, options: { ...opts, projectId: '' } })
return {connector, store}
const connector = new WalletConnect({
actions,
defaultChainId,
options: { ...opts, projectId: '', showQrModal: false },
})
return { connector, store }
}

const accounts = ['0x0000000000000000000000000000000000000000']
const chains = [1, 2, 3]

type SwitchEthereumChainRequestArguments = {
method: 'wallet_switchEthereumChain',
method: 'wallet_switchEthereumChain'
params: [{ chainId: string }]
}

Expand All @@ -28,12 +36,23 @@ const isSwitchEthereumChainRequest = (x: RequestArguments): x is SwitchEthereumC
}

class MockWalletConnectProvider extends MockEIP1193Provider<number> {
opts: EthereumProviderOptions

constructor(opts: EthereumProviderOptions) {
super()
this.chainId = opts.chains[0]
this.opts = opts
}

/** per {@link https://eips.ethereum.org/EIPS/eip-3326#specification EIP-3326} */
public eth_switchEthereumChain = jest.fn((args: string) => null)
public eth_switchEthereumChain = jest.fn(
(/* eslint-disable-line @typescript-eslint/no-unused-vars */ _args: string) => null
)

public request(x: RequestArguments | SwitchEthereumChainRequestArguments): Promise<unknown> {
if (isSwitchEthereumChainRequest(x)) {
this.chainId = parseInt(x.params[0].chainId, 16)
this.emit('chainChanged', this.chainId)
return Promise.resolve(this.eth_switchEthereumChain(JSON.stringify(x)))
} else {
return super.request(x)
Expand All @@ -44,9 +63,30 @@ class MockWalletConnectProvider extends MockEIP1193Provider<number> {
return super.request({ method: 'eth_requestAccounts' })
}

/**
* For testing purposes, let's assume we're connected to all required and optional chains.
* We mock this method later in the test suite to test behavior when optional chains are not supported.
*/
public getConnectedChains() {
return this.opts.chains.concat(this.opts.optionalChains || [])
}

// session is an object when connected, undefined otherwise
get session() {
return this.eth_requestAccounts.mock.calls.length > 0 ? {} : undefined
return this.eth_requestAccounts.mock.calls.length > 0
? {
// We read `accounts` to check what chains from `optionalChains` did we connect to
namespaces: {
eip155: {
accounts: this.getConnectedChains().map((chainId) => `eip155:${chainId}:0x1`),
},
},
}
: undefined
}

public disconnect() {
return this
}
}

Expand All @@ -59,66 +99,95 @@ describe('WalletConnect', () => {
* to only define a subset of `EthereumProvider` that we use internally
*/
// @ts-ignore
wc2InitMock = jest.spyOn(EthereumProvider, 'init').mockImplementation(async (opts) => {
const provider = new MockWalletConnectProvider()
provider.chainId = opts.chains[0]
provider.accounts = accounts
return provider
})
wc2InitMock = jest
.spyOn(EthereumProvider, 'init')
// @ts-ignore
.mockImplementation(async (opts) => {
const provider = new MockWalletConnectProvider(opts)
return provider
})
})

describe('#connectEagerly', () => {
test('should fail when no existing session', async () => {
const {connector} = createTestEnvironment({ chains })
const { connector } = createTestEnvironment({ chains })
await expect(connector.connectEagerly()).rejects.toThrow()
})
})

describe(`#isomorphicInitialize`, () => {
test('should initialize exactly one provider and return a Promise if pending initialization', async () => {
const {connector} = createTestEnvironment({ chains })
const { connector } = createTestEnvironment({ chains })
connector.activate()
connector.activate()
expect(wc2InitMock).toHaveBeenCalledTimes(1)
})
})

describe('#activate', () => {
test('should activate default chain', async () => {
const {connector, store} = createTestEnvironment({ chains })
test('should take first chain as default', async () => {
const { connector, store } = createTestEnvironment({ chains })
await connector.activate()
expect(store.getState()).toEqual({
chainId: chains[0],
accounts,
activating: false,
error: undefined,
})
expect(store.getState().chainId).toEqual(chains[0])
})

test('should activate passed chain', async () => {
const {connector} = createTestEnvironment({ chains })
test('should use `defaultChainId` when passed', async () => {
const { connector, store } = createTestEnvironment({ chains }, 3)
await connector.activate()
expect(store.getState().chainId).toEqual(3)
})

test('should prefer argument over `defaultChainId`', async () => {
const { connector, store } = createTestEnvironment({ chains }, 3)
await connector.activate(2)
expect(connector.provider?.chainId).toEqual(2)
expect(store.getState().chainId).toEqual(2)
})

test('should throw an error when activating with an unknown chain', async () => {
const { connector } = createTestEnvironment({ chains })
await expect(connector.activate(99)).rejects.toThrow()
})

test('should throw an error when using optional chain as default', async () => {
const { connector } = createTestEnvironment({ chains, optionalChains: [8] }, 8)
await expect(connector.activate()).rejects.toThrow()
})

test('should switch to an optional chain', async () => {
const { connector, store } = createTestEnvironment({
chains,
optionalChains: [8],
})
await connector.activate()
await connector.activate(8)
expect(store.getState().chainId).toEqual(8)
})

test('should throw an error for invalid chain', async () => {
const {connector} = createTestEnvironment({ chains })
expect(connector.activate(99)).rejects.toThrow()

test('should throw an error when activating an inactive optional chain', async () => {
jest.spyOn(MockWalletConnectProvider.prototype, 'getConnectedChains').mockReturnValue(chains)
const { connector } = createTestEnvironment({
chains,
optionalChains: [8],
})
await connector.activate()
await expect(connector.activate(8)).rejects.toThrow()
})
test('should switch chain if already connected', async () => {
const {connector} = createTestEnvironment({ chains })

test('should switch chain', async () => {
const { connector, store } = createTestEnvironment({ chains })
await connector.activate()
expect(connector.provider?.chainId).toEqual(1)
expect(store.getState().chainId).toEqual(1)
await connector.activate(2)
expect(connector.provider?.chainId).toEqual(2)
expect(store.getState().chainId).toEqual(2)
})

test('should not switch chain if already connected', async () => {
const {connector} = createTestEnvironment({ chains })
const { connector } = createTestEnvironment({ chains })
await connector.activate(2)
await connector.activate(2)
expect((connector.provider as unknown as MockWalletConnectProvider).eth_switchEthereumChain).toBeCalledTimes(0)
expect(
(connector.provider as unknown as MockWalletConnectProvider).eth_switchEthereumChain
).toHaveBeenCalledTimes(0)
})
})
})
15 changes: 13 additions & 2 deletions packages/walletconnect-v2/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,8 +135,19 @@ export class WalletConnect extends Connector {

if (provider.session) {
if (!desiredChainId || desiredChainId === provider.chainId) return
if (!this.chains.includes(desiredChainId)) {
throw new Error(`Cannot activate chain (${desiredChainId}) that was not included in initial options.chains.`)
// WalletConnect exposes connected accounts, not chains: `eip155:${chainId}:${address}`
const isConnectedToDesiredChain = provider.session.namespaces.eip155.accounts.some((account) =>
account.startsWith(`eip155:${desiredChainId}:`)
)
if (!isConnectedToDesiredChain) {
if (this.options.optionalChains?.includes(desiredChainId)) {
throw new Error(
`Cannot activate an optional chain (${desiredChainId}), as the wallet is not connected to it.\n\tYou should handle this error in application code, as there is no guarantee that a wallet is connected to a chain configured in "optionalChains".`
)
}
throw new Error(
`Unknown chain (${desiredChainId}). Make sure to include any chains you might connect to in the "chains" or "optionalChains" parameters when initializing WalletConnect.`
)
}
return provider.request<void>({
method: 'wallet_switchEthereumChain',
Expand Down
14 changes: 7 additions & 7 deletions packages/walletconnect-v2/src/utils.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,37 +39,37 @@ jest.mock('@walletconnect/jsonrpc-provider', () => ({

describe('getBestUrl', () => {
test('works with a single string', async () => {
const rpc = await getBestUrlMap({0: 'succeed_0'}, 100)
const rpc = await getBestUrlMap({ 0: 'succeed_0' }, 100)
expect(rpc[0]).toBe('succeed_0')
})

test('works with 1 rpc (success)', async () => {
const rpc = await getBestUrlMap({0: ['succeed_0']}, 100)
const rpc = await getBestUrlMap({ 0: ['succeed_0'] }, 100)
expect(rpc[0]).toBe('succeed_0')
})

test('works with 2 urls (success/failure)', async () => {
const rpc = await getBestUrlMap({0: ['succeed_0', 'fail_0']}, 100)
const rpc = await getBestUrlMap({ 0: ['succeed_0', 'fail_0'] }, 100)
expect(rpc[0]).toBe('succeed_0')
})

test('works with 2 urls (failure/success)', async () => {
const rpc = await getBestUrlMap({0: ['fail_0', 'succeed_0']}, 100)
const rpc = await getBestUrlMap({ 0: ['fail_0', 'succeed_0'] }, 100)
expect(rpc[0]).toBe('succeed_0')
})

test('works with 2 successful urls (fast/slow)', async () => {
const rpc = await getBestUrlMap({0: ['succeed_0', 'succeed_1']}, 100)
const rpc = await getBestUrlMap({ 0: ['succeed_0', 'succeed_1'] }, 100)
expect(rpc[0]).toBe('succeed_0')
})

test('works with 2 successful urls (slow/fast)', async () => {
const rpc = await getBestUrlMap({0: ['succeed_1', 'succeed_0']}, 100)
const rpc = await getBestUrlMap({ 0: ['succeed_1', 'succeed_0'] }, 100)
expect(rpc[0]).toBe('succeed_1')
})

test('works with 2 successful urls (after timeout/before timeout)', async () => {
const rpc = await getBestUrlMap({0: ['succeed_100', 'succeed_0']}, 50)
const rpc = await getBestUrlMap({ 0: ['succeed_100', 'succeed_0'] }, 50)
expect(rpc[0]).toBe('succeed_0')
})
})
Expand Down
4 changes: 3 additions & 1 deletion packages/walletconnect-v2/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,9 @@ async function getBestUrl(urls: string | string[], timeout: number): Promise<str
export function getChainsWithDefault(chains: number[], defaultChainId: number) {
const idx = chains.indexOf(defaultChainId)
if (idx === -1) {
throw new Error(`Invalid chainId ${defaultChainId}. Make sure to include it in the "chains" array.`)
throw new Error(
`Invalid chainId ${defaultChainId}. Make sure default chain is included in "chains" - chains specified in "optionalChains" may not be selected as the default, as they may not be supported by the wallet.`
)
}

const ordered = [...chains]
Expand Down
Loading

0 comments on commit d0fafce

Please sign in to comment.