diff --git a/packages/eip1193/src/index.spec.ts b/packages/eip1193/src/index.spec.ts index 0a01cd72b..92863f0af 100644 --- a/packages/eip1193/src/index.spec.ts +++ b/packages/eip1193/src/index.spec.ts @@ -121,7 +121,7 @@ describe('EIP1193', () => { }) test('fails silently', async () => { - connector = new EIP1193(actions, mockProvider) + connector = new EIP1193(actions, mockProvider, true) await yieldThread() expect(store.getState()).toEqual({ @@ -136,7 +136,7 @@ describe('EIP1193', () => { mockProvider.chainId = chainId mockProvider.accounts = accounts - connector = new EIP1193(actions, mockProvider) + connector = new EIP1193(actions, mockProvider, true) await yieldThread() expect(store.getState()).toEqual({ diff --git a/packages/eip1193/src/index.ts b/packages/eip1193/src/index.ts index 6be41687e..53c92c5dd 100644 --- a/packages/eip1193/src/index.ts +++ b/packages/eip1193/src/index.ts @@ -13,39 +13,49 @@ export class EIP1193 extends Connector { * @param provider - An EIP-1193 ({@link https://github.com/ethereum/EIPs/blob/master/EIPS/eip-1193.md}) provider. * @param connectEagerly - A flag indicating whether connection should be initiated when the class is constructed. */ - constructor(actions: Actions, provider: Provider, connectEagerly = true) { + constructor(actions: Actions, provider: Provider, connectEagerly = false) { super(actions) + if (connectEagerly && typeof window === 'undefined') { + throw new Error('connectEagerly = true is invalid for SSR, instead use the connectEagerly method in a useEffect') + } + this.provider = provider this.provider.on('connect', ({ chainId }: ProviderConnectInfo): void => { this.actions.update({ chainId: parseChainId(chainId) }) }) + this.provider.on('disconnect', (error: ProviderRpcError): void => { this.actions.reportError(error) }) + this.provider.on('chainChanged', (chainId: string): void => { this.actions.update({ chainId: parseChainId(chainId) }) }) + this.provider.on('accountsChanged', (accounts: string[]): void => { this.actions.update({ accounts }) }) - if (connectEagerly) { - const cancelActivation = this.actions.startActivation() + if (connectEagerly) void this.connectEagerly() + } + + /** {@inheritdoc Connector.connectEagerly} */ + public async connectEagerly(): Promise { + const cancelActivation = this.actions.startActivation() - Promise.all([ - this.provider.request({ method: 'eth_chainId' }) as Promise, - this.provider.request({ method: 'eth_accounts' }) as Promise, - ]) - .then(([chainId, accounts]) => { - this.actions.update({ chainId: parseChainId(chainId), accounts }) - }) - .catch((error) => { - console.debug('Could not connect eagerly', error) - cancelActivation() - }) - } + return Promise.all([ + this.provider.request({ method: 'eth_chainId' }) as Promise, + this.provider.request({ method: 'eth_accounts' }) as Promise, + ]) + .then(([chainId, accounts]) => { + this.actions.update({ chainId: parseChainId(chainId), accounts }) + }) + .catch((error) => { + console.debug('Could not connect eagerly', error) + cancelActivation() + }) } /** {@inheritdoc Connector.activate} */ diff --git a/packages/example/components/connectors/MetaMaskCard.tsx b/packages/example/components/connectors/MetaMaskCard.tsx index 7181d2358..cd61b4f3e 100644 --- a/packages/example/components/connectors/MetaMaskCard.tsx +++ b/packages/example/components/connectors/MetaMaskCard.tsx @@ -1,3 +1,4 @@ +import { useEffect } from 'react' import { hooks, metaMask } from '../../connectors/metaMask' import { Accounts } from '../Accounts' import { Card } from '../Card' @@ -18,6 +19,11 @@ export default function MetaMaskCard() { const provider = useProvider() const ENSNames = useENSNames(provider) + // attempt to connect eagerly on mount + useEffect(() => { + void metaMask.connectEagerly() + }, []) + return (
diff --git a/packages/example/components/connectors/NetworkCard.tsx b/packages/example/components/connectors/NetworkCard.tsx index d56a34b56..340cf45c8 100644 --- a/packages/example/components/connectors/NetworkCard.tsx +++ b/packages/example/components/connectors/NetworkCard.tsx @@ -1,3 +1,4 @@ +import { useEffect } from 'react' import { hooks, network } from '../../connectors/network' import { Accounts } from '../Accounts' import { Card } from '../Card' @@ -18,6 +19,11 @@ export default function NetworkCard() { const provider = useProvider() const ENSNames = useENSNames(provider) + // attempt to connect eagerly on mount + useEffect(() => { + void network.activate() + }, []) + return (
diff --git a/packages/example/components/connectors/WalletConnectCard.tsx b/packages/example/components/connectors/WalletConnectCard.tsx index 298a20d5a..7d36a408c 100644 --- a/packages/example/components/connectors/WalletConnectCard.tsx +++ b/packages/example/components/connectors/WalletConnectCard.tsx @@ -1,3 +1,4 @@ +import { useEffect } from 'react' import { hooks, walletConnect } from '../../connectors/walletConnect' import { Accounts } from '../Accounts' import { Card } from '../Card' @@ -18,6 +19,11 @@ export default function WalletConnectCard() { const provider = useProvider() const ENSNames = useENSNames(provider) + // attempt to connect eagerly on mount + useEffect(() => { + void walletConnect.connectEagerly() + }, []) + return (
diff --git a/packages/example/components/connectors/WalletLinkCard.tsx b/packages/example/components/connectors/WalletLinkCard.tsx index 06726f72f..9e4be7c80 100644 --- a/packages/example/components/connectors/WalletLinkCard.tsx +++ b/packages/example/components/connectors/WalletLinkCard.tsx @@ -1,3 +1,4 @@ +import { useEffect } from 'react' import { hooks, walletLink } from '../../connectors/walletLink' import { Accounts } from '../Accounts' import { Card } from '../Card' @@ -18,6 +19,11 @@ export default function WalletLinkCard() { const provider = useProvider() const ENSNames = useENSNames(provider) + // attempt to connect eagerly on mount + useEffect(() => { + void walletLink.connectEagerly() + }, []) + return (
diff --git a/packages/example/pages/index.tsx b/packages/example/pages/index.tsx index f48fe59fa..0cadae6b5 100644 --- a/packages/example/pages/index.tsx +++ b/packages/example/pages/index.tsx @@ -1,11 +1,8 @@ -import dynamic from 'next/dynamic' - -const PriorityExample = dynamic(() => import('../components/connectors/PriorityExample'), { ssr: false }) - -const MetaMaskCard = dynamic(() => import('../components/connectors/MetaMaskCard'), { ssr: false }) -const WalletConnectCard = dynamic(() => import('../components/connectors/WalletConnectCard'), { ssr: false }) -const WalletLinkCard = dynamic(() => import('../components/connectors/WalletLinkCard'), { ssr: false }) -const NetworkCard = dynamic(() => import('../components/connectors/NetworkCard'), { ssr: false }) +import MetaMaskCard from '../components/connectors/MetaMaskCard' +import NetworkCard from '../components/connectors/NetworkCard' +import PriorityExample from '../components/connectors/PriorityExample' +import WalletConnectCard from '../components/connectors/WalletConnectCard' +import WalletLinkCard from '../components/connectors/WalletLinkCard' export default function Home() { return ( diff --git a/packages/metamask/src/index.ts b/packages/metamask/src/index.ts index f14b6a697..4e08f0c62 100644 --- a/packages/metamask/src/index.ts +++ b/packages/metamask/src/index.ts @@ -28,36 +28,39 @@ export class MetaMask extends Connector { * @param connectEagerly - A flag indicating whether connection should be initiated when the class is constructed. * @param options - Options to pass to `@metamask/detect-provider` */ - constructor(actions: Actions, connectEagerly = true, options?: Parameters[0]) { + constructor(actions: Actions, connectEagerly = false, options?: Parameters[0]) { super(actions) - this.options = options - if (connectEagerly) { - this.eagerConnection = this.initialize(true) + if (connectEagerly && typeof window === 'undefined') { + throw new Error('connectEagerly = true is invalid for SSR, instead use the connectEagerly method in a useEffect') } + + this.options = options + + if (connectEagerly) void this.connectEagerly() } - private async initialize(connectEagerly: boolean): Promise { - let cancelActivation: () => void - if (connectEagerly) { - cancelActivation = this.actions.startActivation() - } + private async isomorphicInitialize(): Promise { + if (this.eagerConnection) return this.eagerConnection - return import('@metamask/detect-provider') + await (this.eagerConnection = import('@metamask/detect-provider') .then((m) => m.default(this.options)) .then((provider) => { - this.provider = (provider as Provider) ?? undefined + if (provider) { + this.provider = provider as Provider - if (this.provider) { this.provider.on('connect', ({ chainId }: ProviderConnectInfo): void => { this.actions.update({ chainId: parseChainId(chainId) }) }) + this.provider.on('disconnect', (error: ProviderRpcError): void => { this.actions.reportError(error) }) + this.provider.on('chainChanged', (chainId: string): void => { this.actions.update({ chainId: parseChainId(chainId) }) }) + this.provider.on('accountsChanged', (accounts: string[]): void => { if (accounts.length === 0) { // handle this edge case by disconnecting @@ -66,28 +69,32 @@ export class MetaMask extends Connector { this.actions.update({ accounts }) } }) + } + })) + } - if (connectEagerly) { - return Promise.all([ - this.provider.request({ method: 'eth_chainId' }) as Promise, - this.provider.request({ method: 'eth_accounts' }) as Promise, - ]) - .then(([chainId, accounts]) => { - if (accounts.length) { - this.actions.update({ chainId: parseChainId(chainId), accounts }) - } else { - throw new Error('No accounts returned') - } - }) - .catch((error) => { - console.debug('Could not connect eagerly', error) - cancelActivation() - }) - } - } else if (connectEagerly) { - cancelActivation() + /** {@inheritdoc Connector.connectEagerly} */ + public async connectEagerly(): Promise { + const cancelActivation = this.actions.startActivation() + + await this.isomorphicInitialize() + if (!this.provider) return cancelActivation() + + return Promise.all([ + this.provider.request({ method: 'eth_chainId' }) as Promise, + this.provider.request({ method: 'eth_accounts' }) as Promise, + ]) + .then(([chainId, accounts]) => { + if (accounts.length) { + this.actions.update({ chainId: parseChainId(chainId), accounts }) + } else { + throw new Error('No accounts returned') } }) + .catch((error) => { + console.debug('Could not connect eagerly', error) + cancelActivation() + }) } /** @@ -100,21 +107,10 @@ export class MetaMask extends Connector { * specified parameters first, before being prompted to switch. */ public async activate(desiredChainIdOrChainParameters?: number | AddEthereumChainParameter): Promise { - const desiredChainId = - typeof desiredChainIdOrChainParameters === 'number' - ? desiredChainIdOrChainParameters - : desiredChainIdOrChainParameters?.chainId - this.actions.startActivation() - if (!this.eagerConnection) { - this.eagerConnection = this.initialize(false) - } - await this.eagerConnection - - if (!this.provider) { - return this.actions.reportError(new NoMetaMaskError()) - } + await this.isomorphicInitialize() + if (!this.provider) return this.actions.reportError(new NoMetaMaskError()) return Promise.all([ this.provider.request({ method: 'eth_chainId' }) as Promise, @@ -122,14 +118,18 @@ export class MetaMask extends Connector { ]) .then(([chainId, accounts]) => { const receivedChainId = parseChainId(chainId) + const desiredChainId = + typeof desiredChainIdOrChainParameters === 'number' + ? desiredChainIdOrChainParameters + : desiredChainIdOrChainParameters?.chainId // if there's no desired chain, or it's equal to the received, update - if (!desiredChainId || receivedChainId === desiredChainId) { + if (!desiredChainId || receivedChainId === desiredChainId) return this.actions.update({ chainId: receivedChainId, accounts }) - } - // if we're here, we can try to switch networks const desiredChainIdHex = `0x${desiredChainId.toString(16)}` + + // if we're here, we can try to switch networks // eslint-disable-next-line @typescript-eslint/no-non-null-assertion return this.provider!.request({ method: 'wallet_switchEthereumChain', diff --git a/packages/network/src/index.spec.ts b/packages/network/src/index.spec.ts index 10d84f57c..dc1cf3659 100644 --- a/packages/network/src/index.spec.ts +++ b/packages/network/src/index.spec.ts @@ -17,7 +17,7 @@ jest.mock('@ethersproject/experimental', () => ({ const chainId = '0x1' const accounts: string[] = [] -describe('Url', () => { +describe('Network', () => { let store: Web3ReactStore let connector: Network let mockConnector: MockEip1193Bridge @@ -26,7 +26,7 @@ describe('Url', () => { beforeEach(() => { let actions: Actions ;[store, actions] = createWeb3ReactStoreAndActions() - connector = new Network(actions, { 1: 'https://mock.url' }) + connector = new Network(actions, { 1: 'https://mock.url' }, true) }) beforeEach(async () => { @@ -89,7 +89,7 @@ describe('Url', () => { beforeEach(() => { let actions: Actions ;[store, actions] = createWeb3ReactStoreAndActions() - connector = new Network(actions, { 1: ['https://1.mock.url', 'https://2.mock.url'] }) + connector = new Network(actions, { 1: ['https://1.mock.url', 'https://2.mock.url'] }, true) }) beforeEach(async () => { @@ -114,7 +114,7 @@ describe('Url', () => { beforeEach(() => { let actions: Actions ;[store, actions] = createWeb3ReactStoreAndActions() - connector = new Network(actions, { 1: 'https://mainnet.mock.url', 2: 'https://testnet.mock.url' }) + connector = new Network(actions, { 1: 'https://mainnet.mock.url', 2: 'https://testnet.mock.url' }, true) }) beforeEach(async () => { diff --git a/packages/network/src/index.ts b/packages/network/src/index.ts index 807bfad0d..9f325e0ca 100644 --- a/packages/network/src/index.ts +++ b/packages/network/src/index.ts @@ -7,82 +7,50 @@ type url = string | ConnectionInfo export class Network extends Connector { /** {@inheritdoc Connector.provider} */ - provider: Eip1193Bridge | undefined + public provider: Eip1193Bridge | undefined - private urlMap: { [chainId: number]: url[] } - private chainId: number - private providerCache: { [chainId: number]: Eip1193Bridge } = {} + private urlMap: Record + private providerCache: Record | undefined> = {} /** * @param urlMap - A mapping from chainIds to RPC urls. * @param connectEagerly - A flag indicating whether connection should be initiated when the class is constructed. */ - constructor(actions: Actions, urlMap: { [chainId: number]: url | url[] }, connectEagerly = true) { + constructor(actions: Actions, urlMap: { [chainId: number]: url | url[] }, connectEagerly = false) { super(actions) + + if (connectEagerly && typeof window === 'undefined') { + throw new Error('connectEagerly = true is invalid for SSR, instead use the activate method in a useEffect') + } + this.urlMap = Object.keys(urlMap).reduce<{ [chainId: number]: url[] }>((accumulator, chainId) => { const urls = urlMap[Number(chainId)] accumulator[Number(chainId)] = Array.isArray(urls) ? urls : [urls] return accumulator }, {}) - // use the first chainId in urlMap as the default - this.chainId = Number(Object.keys(this.urlMap)[0]) - if (connectEagerly) { - void this.initialize() - } + if (connectEagerly) void this.activate() } - private async initialize(): Promise { - this.provider = undefined - this.actions.startActivation() - - // cache the desired chainId before async logic - const chainId = this.chainId - - // populate the provider cache if necessary - if (!this.providerCache[chainId]) { - // instantiate new provider - const [{ JsonRpcProvider, FallbackProvider }, Eip1193Bridge] = await Promise.all([ - import('@ethersproject/providers').then(({ JsonRpcProvider, FallbackProvider }) => ({ - JsonRpcProvider, - FallbackProvider, - })), - import('@ethersproject/experimental').then(({ Eip1193Bridge }) => Eip1193Bridge), - ]) + private async isomorphicInitialize(chainId: number): Promise { + if (this.providerCache[chainId]) return this.providerCache[chainId] as Promise + return (this.providerCache[chainId] = Promise.all([ + import('@ethersproject/providers').then(({ JsonRpcProvider, FallbackProvider }) => ({ + JsonRpcProvider, + FallbackProvider, + })), + import('@ethersproject/experimental').then(({ Eip1193Bridge }) => Eip1193Bridge), + ]).then(([{ JsonRpcProvider, FallbackProvider }, Eip1193Bridge]) => { const urls = this.urlMap[chainId] const providers = urls.map((url) => new JsonRpcProvider(url, chainId)) - const provider = new Eip1193Bridge( + + return new Eip1193Bridge( providers[0].getSigner(), providers.length === 1 ? providers[0] : new FallbackProvider(providers) ) - - this.providerCache[chainId] = provider - } - - // once we're here, the cache is guaranteed to be initialized - // so, if the current chainId still matches the one at the beginning of the call, update - if (chainId === this.chainId) { - this.provider = this.providerCache[chainId] - - return this.provider - .request({ method: 'eth_chainId' }) - .then((returnedChainId: number) => { - if (returnedChainId !== chainId) { - // this means the returned chainId was unexpected, i.e. the provided url(s) were wrong - throw new Error(`expected chainId ${chainId}, received ${returnedChainId}`) - } - - // again we have to make sure the chainIds match, to prevent race conditions - if (chainId === this.chainId) { - this.actions.update({ chainId, accounts: [] }) - } - }) - .catch((error: Error) => { - this.actions.reportError(error) - }) - } + })) } /** @@ -91,13 +59,17 @@ export class Network extends Connector { * @param desiredChainId - The desired chain to connect to. */ public async activate(desiredChainId = Number(Object.keys(this.urlMap)[0])): Promise { - if (this.urlMap[desiredChainId] === undefined) { - throw new Error(`no url(s) provided for desiredChainId ${desiredChainId}`) - } + this.actions.startActivation() - // set the connector's chainId to the target, to prevent race conditions - this.chainId = desiredChainId + this.provider = await this.isomorphicInitialize(desiredChainId) - return this.initialize() + return this.provider + .request({ method: 'eth_chainId' }) + .then((chainId: number) => { + this.actions.update({ chainId, accounts: [] }) + }) + .catch((error: Error) => { + this.actions.reportError(error) + }) } } diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 8febfced2..3f6239610 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -87,6 +87,11 @@ export abstract class Connector { this.actions = actions } + /** + * Attempt to initiate a connection, failing silently + */ + public connectEagerly?(...args: unknown[]): Promise | void + /** * Initiate a connection. */ diff --git a/packages/url/src/index.spec.ts b/packages/url/src/index.spec.ts index f24971047..d5eff8c7b 100644 --- a/packages/url/src/index.spec.ts +++ b/packages/url/src/index.spec.ts @@ -47,7 +47,7 @@ describe('Url', () => { beforeEach(() => { let actions: Actions ;[store, actions] = createWeb3ReactStoreAndActions() - connector = new Url(actions, 'https://mock.url') + connector = new Url(actions, 'https://mock.url', true) }) beforeEach(async () => { diff --git a/packages/url/src/index.ts b/packages/url/src/index.ts index 434be52eb..ed4be8132 100644 --- a/packages/url/src/index.ts +++ b/packages/url/src/index.ts @@ -7,40 +7,47 @@ type url = string | ConnectionInfo export class Url extends Connector { /** {@inheritdoc Connector.provider} */ - provider: Eip1193Bridge | undefined + public provider: Eip1193Bridge | undefined + private eagerConnection?: Promise private url: url /** * @param url - An RPC url. * @param connectEagerly - A flag indicating whether connection should be initiated when the class is constructed. */ - constructor(actions: Actions, url: url, connectEagerly = true) { + constructor(actions: Actions, url: url, connectEagerly = false) { super(actions) - this.url = url - if (connectEagerly) { - void this.initialize() + if (connectEagerly && typeof window === 'undefined') { + throw new Error('connectEagerly = true is invalid for SSR, instead use the activate method in a useEffect') } - } - private async initialize(): Promise { - this.actions.startActivation() + this.url = url - // create the provider if necessary - if (!this.provider) { - // instantiate new provider - const [JsonRpcProvider, Eip1193Bridge] = await Promise.all([ - import('@ethersproject/providers').then(({ JsonRpcProvider }) => JsonRpcProvider), - import('@ethersproject/experimental').then(({ Eip1193Bridge }) => Eip1193Bridge), - ]) + if (connectEagerly) void this.activate() + } + private async isomorphicInitialize() { + if (this.eagerConnection) return this.eagerConnection + + await (this.eagerConnection = Promise.all([ + import('@ethersproject/providers').then(({ JsonRpcProvider }) => JsonRpcProvider), + import('@ethersproject/experimental').then(({ Eip1193Bridge }) => Eip1193Bridge), + ]).then(([JsonRpcProvider, Eip1193Bridge]) => { const provider = new JsonRpcProvider(this.url) this.provider = new Eip1193Bridge(provider.getSigner(), provider) - } + })) + } - return this.provider - .request({ method: 'eth_chainId' }) + /** {@inheritdoc Connector.activate} */ + public async activate(): Promise { + this.actions.startActivation() + + await this.isomorphicInitialize() + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return this.provider!.request({ method: 'eth_chainId' }) .then((chainId: number) => { this.actions.update({ chainId, accounts: [] }) }) @@ -48,9 +55,4 @@ export class Url extends Connector { this.actions.reportError(error) }) } - - /** {@inheritdoc Connector.activate} */ - public async activate(): Promise { - return this.initialize() - } } diff --git a/packages/walletconnect/src/index.spec.ts b/packages/walletconnect/src/index.spec.ts index 3c8daf08f..362aae6e0 100644 --- a/packages/walletconnect/src/index.spec.ts +++ b/packages/walletconnect/src/index.spec.ts @@ -23,7 +23,7 @@ jest.mock('@walletconnect/ethereum-provider', () => MockMockWalletConnectProvide const chainId = '0x1' const accounts: string[] = [] -describe('WalletConnect', () => { +describe.only('WalletConnect', () => { let store: Web3ReactStore let connector: WalletConnect let mockConnector: MockMockWalletConnectProvider @@ -32,7 +32,7 @@ describe('WalletConnect', () => { beforeEach(() => { let actions: Actions ;[store, actions] = createWeb3ReactStoreAndActions() - connector = new WalletConnect(actions, {}) + connector = new WalletConnect(actions, {}, true) }) beforeEach(async () => { diff --git a/packages/walletconnect/src/index.ts b/packages/walletconnect/src/index.ts index 8c803c038..d320b7dc0 100644 --- a/packages/walletconnect/src/index.ts +++ b/packages/walletconnect/src/index.ts @@ -14,7 +14,7 @@ function parseChainId(chainId: string | number) { export class WalletConnect extends Connector { /** {@inheritdoc Connector.provider} */ - provider: MockWalletConnectProvider | undefined + public provider: MockWalletConnectProvider | undefined private readonly options?: IWCEthRpcConnectionOptions private eagerConnection?: Promise @@ -27,16 +27,19 @@ export class WalletConnect extends Connector { constructor( actions: Actions, options: IWCEthRpcConnectionOptions, - connectEagerly = true, + connectEagerly = false, treatModalCloseAsError = true ) { super(actions) + + if (connectEagerly && typeof window === 'undefined') { + throw new Error('connectEagerly = true is invalid for SSR, instead use the connectEagerly method in a useEffect') + } + this.options = options this.treatModalCloseAsError = treatModalCloseAsError - if (connectEagerly) { - this.eagerConnection = this.initialize(true) - } + if (connectEagerly) void this.connectEagerly() } private disconnectListener = (error: ProviderRpcError | undefined): void => { @@ -51,13 +54,10 @@ export class WalletConnect extends Connector { this.actions.update({ accounts }) } - private async initialize(connectEagerly: boolean, chainId?: number): Promise { - let cancelActivation: () => void - if (connectEagerly) { - cancelActivation = this.actions.startActivation() - } + private async isomorphicInitialize(chainId?: number): Promise { + if (this.eagerConnection) return this.eagerConnection - return import('@walletconnect/ethereum-provider').then((m) => { + await (this.eagerConnection = import('@walletconnect/ethereum-provider').then((m) => { this.provider = new m.default({ ...this.options, ...(chainId ? { chainId } : undefined), @@ -66,31 +66,35 @@ export class WalletConnect extends Connector { this.provider.on('disconnect', this.disconnectListener) this.provider.on('chainChanged', this.chainChangedListener) this.provider.on('accountsChanged', this.accountsChangedListener) + })) + } - if (connectEagerly) { - if (this.provider.connected) { - return ( - Promise.all([ - this.provider.request({ method: 'eth_chainId' }), - this.provider.request({ method: 'eth_accounts' }), - ]) as Promise<[number | string, string[]]> - ) - .then(([chainId, accounts]) => { - if (accounts?.length) { - this.actions.update({ chainId: parseChainId(chainId), accounts }) - } else { - throw new Error('No accounts returned') - } - }) - .catch((error) => { - console.debug('Could not connect eagerly', error) - cancelActivation() - }) + /** {@inheritdoc Connector.connectEagerly} */ + public async connectEagerly(): Promise { + const cancelActivation = this.actions.startActivation() + + await this.isomorphicInitialize() + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + if (this.provider!.connected) { + try { + // for walletconnect, we always use sequential instead of parallel fetches because otherwise + // chainId defaults to 1 even if the connecting wallet isn't on mainnet + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const accounts = await this.provider!.request({ method: 'eth_accounts' }) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const chainId = parseChainId(await this.provider!.request({ method: 'eth_chainId' })) + + if (accounts.length) { + this.actions.update({ chainId, accounts }) } else { - cancelActivation() + throw new Error('No accounts returned') } + } catch (error) { + console.debug('Could not connect eagerly', error) + cancelActivation() } - }) + } } /** @@ -101,10 +105,9 @@ export class WalletConnect extends Connector { * to the chain, if their wallet supports it. */ public async activate(desiredChainId?: number): Promise { - // this early return clause catches some common cases if we're already connected + // this early return clause handles all cases if we're already connected if (this.provider?.connected) { - if (!desiredChainId) return - if (desiredChainId === this.provider.chainId) return + if (!desiredChainId || desiredChainId === this.provider.chainId) return const desiredChainIdHex = `0x${desiredChainId.toString(16)}` return this.provider @@ -112,28 +115,16 @@ export class WalletConnect extends Connector { method: 'wallet_switchEthereumChain', params: [{ chainId: desiredChainIdHex }], }) - .catch(() => { - void 0 - }) - } - - // if we're trying to connect to a specific chain, we may have to re-initialize - if (desiredChainId && desiredChainId !== this.provider?.chainId) { - await this.deactivate() + .catch(() => void 0) } this.actions.startActivation() - if (!this.eagerConnection) { - this.eagerConnection = this.initialize(false, desiredChainId) - } - await this.eagerConnection - - const wasConnected = !!this.provider?.connected + // if we're trying to connect to a specific chain that we're not already initialized for, we have to re-initialize + if (desiredChainId && desiredChainId !== this.provider?.chainId) await this.deactivate() + await this.isomorphicInitialize(desiredChainId) try { - // these are sequential instead of parallel because otherwise, chainId defaults to 1 even - // if the connecting wallet isn't on mainnet // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const accounts = await this.provider!.request({ method: 'eth_requestAccounts' }) // eslint-disable-next-line @typescript-eslint/no-non-null-assertion @@ -143,28 +134,24 @@ export class WalletConnect extends Connector { return this.actions.update({ chainId, accounts }) } - // because e.g. metamask doesn't support wallet_switchEthereumChain, we have to report first-time connections, + // because e.g. metamask doesn't support wallet_switchEthereumChain, we have to report connections, // even if the chainId isn't necessarily the desired one. this is ok because in e.g. rainbow, // we won't report a connection to the wrong chain while the switch is pending because of the re-initialization // logic above, which ensures first-time connections are to the correct chain in the first place - if (!wasConnected) this.actions.update({ chainId, accounts }) - - // try to switch to the desired chain, ignoring errors - if (desiredChainId && desiredChainId !== chainId) { - const desiredChainIdHex = `0x${desiredChainId.toString(16)}` - return this.provider - ?.request({ - method: 'wallet_switchEthereumChain', - params: [{ chainId: desiredChainIdHex }], - }) - .catch(() => { - void 0 - }) - } + this.actions.update({ chainId, accounts }) + + // if we're here, we can try to switch networks, ignoring errors + const desiredChainIdHex = `0x${desiredChainId.toString(16)}` + return this.provider + ?.request({ + method: 'wallet_switchEthereumChain', + params: [{ chainId: desiredChainIdHex }], + }) + .catch(() => void 0) } catch (error) { // this condition is a bit of a hack :/ - // if a user triggers the walletconnect modal, closes it, and then tries to connect again, the modal will not trigger. - // the logic below prevents this from happening + // if a user triggers the walletconnect modal, closes it, and then tries to connect again, + // the modal will not trigger. the logic below prevents this from happening if ((error as Error).message === 'User closed modal') { await this.deactivate(this.treatModalCloseAsError ? (error as Error) : undefined) } else { diff --git a/packages/walletlink/src/index.spec.ts b/packages/walletlink/src/index.spec.ts index 48ea3da63..4026aef54 100644 --- a/packages/walletlink/src/index.spec.ts +++ b/packages/walletlink/src/index.spec.ts @@ -23,10 +23,14 @@ describe('WalletLink', () => { beforeEach(() => { let actions: Actions ;[store, actions] = createWeb3ReactStoreAndActions() - connector = new WalletLink(actions, { - appName: 'test', - url: 'https://mock.url', - }) + connector = new WalletLink( + actions, + { + appName: 'test', + url: 'https://mock.url', + }, + true + ) }) beforeEach(async () => { diff --git a/packages/walletlink/src/index.ts b/packages/walletlink/src/index.ts index 3da4fd391..e26f8f407 100644 --- a/packages/walletlink/src/index.ts +++ b/packages/walletlink/src/index.ts @@ -23,70 +23,74 @@ export class WalletLink extends Connector { * @param options - Options to pass to `walletlink` * @param connectEagerly - A flag indicating whether connection should be initiated when the class is constructed. */ - constructor(actions: Actions, options: WalletLinkOptions & { url: string }, connectEagerly = true) { + constructor(actions: Actions, options: WalletLinkOptions & { url: string }, connectEagerly = false) { super(actions) - this.options = options - if (connectEagerly) { - this.eagerConnection = this.initialize(true) + if (connectEagerly && typeof window === 'undefined') { + throw new Error('connectEagerly = true is invalid for SSR, instead use the connectEagerly method in a useEffect') } - } - private connectListener = ({ chainId }: ProviderConnectInfo): void => { - this.actions.update({ chainId: parseChainId(chainId) }) - } + this.options = options - private disconnectListener = (error: ProviderRpcError): void => { - this.actions.reportError(error) + if (connectEagerly) void this.connectEagerly() } - private chainChangedListener = (chainId: string): void => { - this.actions.update({ chainId: parseChainId(chainId) }) + // the `connected` property, is bugged, but this works as a hack to check connection status + private get connected() { + return !!this.provider?.selectedAddress } - private accountsChangedListener = (accounts: string[]): void => { - this.actions.update({ accounts }) - } + private async isomorphicInitialize(): Promise { + if (this.eagerConnection) return this.eagerConnection - private async initialize(connectEagerly: boolean): Promise { - let cancelActivation: () => void - if (connectEagerly) { - cancelActivation = this.actions.startActivation() - } + await (this.eagerConnection = import('walletlink').then((m) => { + const { url, ...options } = this.options + this.walletLink = new m.WalletLink(options) + this.provider = this.walletLink.makeWeb3Provider(url) - const { url, ...options } = this.options + this.provider.on('connect', ({ chainId }: ProviderConnectInfo): void => { + this.actions.update({ chainId: parseChainId(chainId) }) + }) - return import('walletlink').then((m) => { - if (!this.walletLink) { - this.walletLink = new m.WalletLink(options) - } - this.provider = this.walletLink.makeWeb3Provider(url) + this.provider.on('disconnect', (error: ProviderRpcError): void => { + this.actions.reportError(error) + }) - this.provider.on('connect', this.connectListener) - this.provider.on('disconnect', this.disconnectListener) - this.provider.on('chainChanged', this.chainChangedListener) - this.provider.on('accountsChanged', this.accountsChangedListener) - - if (connectEagerly) { - return ( - Promise.all([ - this.provider.request({ method: 'eth_chainId' }), - this.provider.request({ method: 'eth_accounts' }), - ]) as Promise<[string, string[]]> - ) - .then(([chainId, accounts]) => { - if (accounts?.length) { - this.actions.update({ chainId: parseChainId(chainId), accounts }) - } else { - throw new Error('No accounts returned') - } - }) - .catch((error) => { - console.debug('Could not connect eagerly', error) - cancelActivation() - }) - } - }) + this.provider.on('chainChanged', (chainId: string): void => { + this.actions.update({ chainId: parseChainId(chainId) }) + }) + + this.provider.on('accountsChanged', (accounts: string[]): void => { + this.actions.update({ accounts }) + }) + })) + } + + /** {@inheritdoc Connector.connectEagerly} */ + public async connectEagerly(): Promise { + const cancelActivation = this.actions.startActivation() + + await this.isomorphicInitialize() + + if (this.connected) { + return Promise.all([ + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.provider!.request({ method: 'eth_chainId' }), + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.provider!.request({ method: 'eth_accounts' }), + ]) + .then(([chainId, accounts]) => { + if (accounts.length) { + this.actions.update({ chainId: parseChainId(chainId), accounts }) + } else { + throw new Error('No accounts returned') + } + }) + .catch((error) => { + console.debug('Could not connect eagerly', error) + cancelActivation() + }) + } } /** @@ -104,50 +108,46 @@ export class WalletLink extends Connector { ? desiredChainIdOrChainParameters : desiredChainIdOrChainParameters?.chainId - // the `connected` property, is bugged, but this works as a hack to check connection status - if (this.provider?.selectedAddress) { - if (!desiredChainId) return - if (desiredChainId === parseChainId(this.provider.chainId)) return + if (this.connected) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + if (!desiredChainId || desiredChainId === parseChainId(this.provider!.chainId)) return const desiredChainIdHex = `0x${desiredChainId.toString(16)}` - return this.provider - .request({ - method: 'wallet_switchEthereumChain', - params: [{ chainId: desiredChainIdHex }], - }) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return this.provider!.request({ + method: 'wallet_switchEthereumChain', + params: [{ chainId: desiredChainIdHex }], + }) .catch(async (error: ProviderRpcError) => { if (error.code === 4902 && typeof desiredChainIdOrChainParameters !== 'number') { // if we're here, we can try to add a new network - await this.provider?.request({ + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return this.provider!.request({ method: 'wallet_addEthereumChain', params: [{ ...desiredChainIdOrChainParameters, chainId: desiredChainIdHex }], }) } else { - this.actions.reportError(error) + throw error } }) + .catch((error: ProviderRpcError) => { + this.actions.reportError(error) + }) } this.actions.startActivation() - - if (!this.eagerConnection) { - this.eagerConnection = this.initialize(false) - } - await this.eagerConnection - - return ( - Promise.all([ - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - this.provider!.request({ method: 'eth_chainId' }), - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - this.provider!.request({ method: 'eth_requestAccounts' }), - ]) as Promise<[string, string[]]> - ) + await this.isomorphicInitialize() + + return Promise.all([ + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.provider!.request({ method: 'eth_chainId' }), + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.provider!.request({ method: 'eth_requestAccounts' }), + ]) .then(([chainId, accounts]) => { const receivedChainId = parseChainId(chainId) - // if there's no desired chain, or it's equal to the received, update - if (!desiredChainId || receivedChainId === desiredChainId) { + if (!desiredChainId || desiredChainId === receivedChainId) { return this.actions.update({ chainId: receivedChainId, accounts }) } @@ -161,7 +161,8 @@ export class WalletLink extends Connector { .catch(async (error: ProviderRpcError) => { if (error.code === 4902 && typeof desiredChainIdOrChainParameters !== 'number') { // if we're here, we can try to add a new network - await this.provider?.request({ + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return this.provider!.request({ method: 'wallet_addEthereumChain', params: [{ ...desiredChainIdOrChainParameters, chainId: desiredChainIdHex }], }) @@ -177,12 +178,6 @@ export class WalletLink extends Connector { /** {@inheritdoc Connector.deactivate} */ public deactivate(): void { - this.provider?.off('connect', this.disconnectListener) - this.provider?.off('disconnect', this.disconnectListener) - this.provider?.off('chainChanged', this.chainChangedListener) - this.provider?.off('accountsChanged', this.accountsChangedListener) - this.provider = undefined - this.eagerConnection = undefined this.walletLink?.disconnect() } }