diff --git a/example/connectors/walletConnectV2.ts b/example/connectors/walletConnectV2.ts index 3747852..96a5703 100644 --- a/example/connectors/walletConnectV2.ts +++ b/example/connectors/walletConnectV2.ts @@ -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( (actions) => new WalletConnectV2({ actions, options: { projectId: process.env.walletConnectProjectId, - chains: Object.keys(MAINNET_CHAINS).map(Number), + chains: [mainnet], + optionalChains, + showQrModal: true, }, }) ) diff --git a/packages/walletconnect-v2/package.json b/packages/walletconnect-v2/package.json index 6274138..ff425d0 100644 --- a/packages/walletconnect-v2/package.json +++ b/packages/walletconnect-v2/package.json @@ -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" diff --git a/packages/walletconnect-v2/src/index.spec.ts b/packages/walletconnect-v2/src/index.spec.ts index a0838f9..50a4eae 100644 --- a/packages/walletconnect-v2/src/index.spec.ts +++ b/packages/walletconnect-v2/src/index.spec.ts @@ -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) => { +type EthereumProviderOptions = Parameters[0] + +const createTestEnvironment = ( + opts: Omit, + 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 }] } @@ -28,12 +36,23 @@ const isSwitchEthereumChainRequest = (x: RequestArguments): x is SwitchEthereumC } class MockWalletConnectProvider extends MockEIP1193Provider { + 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 { 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) @@ -44,9 +63,30 @@ class MockWalletConnectProvider extends MockEIP1193Provider { 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 } } @@ -59,24 +99,25 @@ 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) @@ -84,41 +125,69 @@ describe('WalletConnect', () => { }) 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) }) }) }) diff --git a/packages/walletconnect-v2/src/index.ts b/packages/walletconnect-v2/src/index.ts index fb3dce4..1b1672c 100644 --- a/packages/walletconnect-v2/src/index.ts +++ b/packages/walletconnect-v2/src/index.ts @@ -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({ method: 'wallet_switchEthereumChain', diff --git a/packages/walletconnect-v2/src/utils.spec.ts b/packages/walletconnect-v2/src/utils.spec.ts index cb667b5..95c19eb 100644 --- a/packages/walletconnect-v2/src/utils.spec.ts +++ b/packages/walletconnect-v2/src/utils.spec.ts @@ -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') }) }) diff --git a/packages/walletconnect-v2/src/utils.ts b/packages/walletconnect-v2/src/utils.ts index f09a125..5e89633 100644 --- a/packages/walletconnect-v2/src/utils.ts +++ b/packages/walletconnect-v2/src/utils.ts @@ -88,7 +88,9 @@ async function getBestUrl(urls: string | string[], timeout: number): Promise