diff --git a/package-lock.json b/package-lock.json index 2498c78..bd0a13a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,7 @@ "eslint-plugin-react": "^7.22.0", "eslint-plugin-react-hooks": "^4.2.0", "eslint-plugin-testing-library": "^3.10.1", - "eth-testing": "^1.1.0-alpha.15", + "eth-testing": "^1.9.1", "husky": "^4.3.7", "jest": "^26.6.3", "lint-staged": "^10.5.3", @@ -3483,6 +3483,24 @@ "integrity": "sha512-9IK9EadsbHo6jLWIpxpR6pL0sazTXV6+SQv25ZB+F7Bj9mJNaOc4nCRabwd5M/JwmUa8idz6Eci6eKfJryPs6Q==", "dev": true }, + "node_modules/abitype": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/abitype/-/abitype-0.1.8.tgz", + "integrity": "sha512-2pde0KepTzdfu19ZrzYTYVIWo69+6UbBCY4B1RDiwWgo2XZtFSJhF6C+XThuRXbbZ823J0Rw1Y5cP0NXYVcCdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wagmi-dev" + } + ], + "engines": { + "pnpm": ">=7" + }, + "peerDependencies": { + "typescript": ">=4.7.4" + } + }, "node_modules/acorn": { "version": "7.4.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", @@ -6129,11 +6147,12 @@ } }, "node_modules/eth-testing": { - "version": "1.1.0-alpha.15", - "resolved": "https://registry.npmjs.org/eth-testing/-/eth-testing-1.1.0-alpha.15.tgz", - "integrity": "sha512-2LNK5XRbGEQdgX9FUisFmHSClALpvBsR471o209HqALdp1kxbhsdCcCthiLzLEflcU5bv0XkKZ39sKjZPx8yNw==", + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/eth-testing/-/eth-testing-1.9.1.tgz", + "integrity": "sha512-1lM4c8kNtgdYYiOZIq7R2P+Rs6hqf+VDQP7/wjqDCUyncdqSSeF5QsmhUqxfOW0qngbow6Y9oWz24ETRwwKNrA==", "dev": true, "dependencies": { + "abitype": "^0.1.6", "ethers": "^5.5.4" } }, @@ -17113,9 +17132,9 @@ } }, "node_modules/typescript": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.1.3.tgz", - "integrity": "sha512-B3ZIOf1IKeH2ixgHhj6la6xdwR9QrLC5d1VKeCSY4tvkqhF2eqd9O7txNlS0PO3GrBAFIdr3L1ndNwteUbZLYg==", + "version": "4.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.3.tgz", + "integrity": "sha512-CIfGzTelbKNEnLpLdGFgdyKhG23CKdKgQPOBc+OUNrkJ2vr+KSzsSV5kq5iWhEQbok+quxgGzrAtGWCyU7tHnA==", "dev": true, "bin": { "tsc": "bin/tsc", @@ -20213,6 +20232,13 @@ "integrity": "sha512-9IK9EadsbHo6jLWIpxpR6pL0sazTXV6+SQv25ZB+F7Bj9mJNaOc4nCRabwd5M/JwmUa8idz6Eci6eKfJryPs6Q==", "dev": true }, + "abitype": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/abitype/-/abitype-0.1.8.tgz", + "integrity": "sha512-2pde0KepTzdfu19ZrzYTYVIWo69+6UbBCY4B1RDiwWgo2XZtFSJhF6C+XThuRXbbZ823J0Rw1Y5cP0NXYVcCdQ==", + "dev": true, + "requires": {} + }, "acorn": { "version": "7.4.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", @@ -22230,11 +22256,12 @@ "dev": true }, "eth-testing": { - "version": "1.1.0-alpha.15", - "resolved": "https://registry.npmjs.org/eth-testing/-/eth-testing-1.1.0-alpha.15.tgz", - "integrity": "sha512-2LNK5XRbGEQdgX9FUisFmHSClALpvBsR471o209HqALdp1kxbhsdCcCthiLzLEflcU5bv0XkKZ39sKjZPx8yNw==", + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/eth-testing/-/eth-testing-1.9.1.tgz", + "integrity": "sha512-1lM4c8kNtgdYYiOZIq7R2P+Rs6hqf+VDQP7/wjqDCUyncdqSSeF5QsmhUqxfOW0qngbow6Y9oWz24ETRwwKNrA==", "dev": true, "requires": { + "abitype": "^0.1.6", "ethers": "^5.5.4" } }, @@ -30511,9 +30538,9 @@ } }, "typescript": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.1.3.tgz", - "integrity": "sha512-B3ZIOf1IKeH2ixgHhj6la6xdwR9QrLC5d1VKeCSY4tvkqhF2eqd9O7txNlS0PO3GrBAFIdr3L1ndNwteUbZLYg==", + "version": "4.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.3.tgz", + "integrity": "sha512-CIfGzTelbKNEnLpLdGFgdyKhG23CKdKgQPOBc+OUNrkJ2vr+KSzsSV5kq5iWhEQbok+quxgGzrAtGWCyU7tHnA==", "dev": true }, "uglify-js": { diff --git a/package.json b/package.json index 98c6eb6..822550d 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "eslint-plugin-react": "^7.22.0", "eslint-plugin-react-hooks": "^4.2.0", "eslint-plugin-testing-library": "^3.10.1", - "eth-testing": "^1.1.0-alpha.15", + "eth-testing": "^1.9.1", "husky": "^4.3.7", "jest": "^26.6.3", "lint-staged": "^10.5.3", diff --git a/src/__tests__/ethereum-conflicts.test.tsx b/src/__tests__/ethereum-conflicts.test.tsx new file mode 100644 index 0000000..10bb3b2 --- /dev/null +++ b/src/__tests__/ethereum-conflicts.test.tsx @@ -0,0 +1,85 @@ +import { renderHook } from "@testing-library/react-hooks"; +import { generateTestingUtils } from "eth-testing"; + +import { useMetaMask, MetaMaskProvider } from "../"; + +describe("`window.ethereum` conflict tests", () => { + describe("when the `ethereum` object has a `providerMap` field", () => { + test("when the `providers` does not have a `MetaMask` provider, it should synchronise in `unavailable` status", async () => { + let originalEth = (window as any).ethereum; + const testingUtils = generateTestingUtils(); + const coinbaseProvider = testingUtils.getProvider(); + const ethereum = { + providers: [coinbaseProvider], + }; + (window as any).ethereum = ethereum; + + const { result } = renderHook(useMetaMask, { wrapper: MetaMaskProvider }); + + expect(result.current.status).toEqual("unavailable"); + + (window as any).ethereum = originalEth; + }); + + test("when the `providers` does have a valid MetaMask provider, it should synchronise in `notConnected` status", async () => { + let originalEth = (window as any).ethereum; + const testingUtils = generateTestingUtils(); + const coinbaseProvider = testingUtils.getProvider(); + const metaMaskTestingUtils = generateTestingUtils({ + providerType: "MetaMask", + }); + const metaMaskProvider = metaMaskTestingUtils.getProvider(); + const ethereum = { + providers: [coinbaseProvider, metaMaskProvider], + }; + (window as any).ethereum = ethereum; + + metaMaskTestingUtils.mockNotConnectedWallet(); + + const { result, waitForNextUpdate } = renderHook(useMetaMask, { + wrapper: MetaMaskProvider, + }); + + expect(result.current.status).toEqual("initializing"); + + await waitForNextUpdate(); + + expect(result.current.status).toEqual("notConnected"); + + (window as any).ethereum = originalEth; + }); + }); + + test("when the `MetaMask` provider is corrupted or removed in the meantime, it should throw", async () => { + let originalEth = (window as any).ethereum; + const testingUtils = generateTestingUtils(); + const coinbaseProvider = testingUtils.getProvider(); + const metaMaskTestingUtils = generateTestingUtils({ + providerType: "MetaMask", + }); + const metaMaskProvider = metaMaskTestingUtils.getProvider(); + const providers = [coinbaseProvider, metaMaskProvider]; + const ethereum = { providers }; + (window as any).ethereum = ethereum; + + metaMaskTestingUtils.mockNotConnectedWallet(); + + const { result, waitForNextUpdate } = renderHook(useMetaMask, { + wrapper: MetaMaskProvider, + }); + + expect(result.current.status).toEqual("initializing"); + + await waitForNextUpdate(); + + expect(result.current.status).toEqual("notConnected"); + + providers.pop(); + + expect(() => result.current.connect()).toThrowError( + "MetaMask provider must be present in order to use this method" + ); + + (window as any).ethereum = originalEth; + }); +}); diff --git a/src/__tests__/index.test.tsx b/src/__tests__/index.test.tsx index 67dbc67..545d78b 100644 --- a/src/__tests__/index.test.tsx +++ b/src/__tests__/index.test.tsx @@ -1,5 +1,5 @@ import { act, renderHook } from "@testing-library/react-hooks"; -import { setupEthTesting } from "eth-testing"; +import { generateTestingUtils } from "eth-testing"; import { useMetaMask, MetaMaskProvider } from "../"; @@ -22,6 +22,15 @@ describe("MetaMask provider", () => { describe("when MetaMask is not available", () => { test("when there is no `ethereum` object in the window, it should synchronise into `unavailable` status", async () => { + let originalEth = (window as any).ethereum; + const testingUtils = generateTestingUtils(); + const unknownProvider = testingUtils.getProvider(); + (window as any).ethereum = unknownProvider; + const { result } = renderHook(useMetaMask, { wrapper: MetaMaskProvider }); + expect(result.current.status).toEqual("unavailable"); + (window as any).ethereum = originalEth; + }); + test("when there is an `ethereum` object in the window but it is not a MetaMask type, it should synchronise into `unavailable` status", async () => { const { result } = renderHook(useMetaMask, { wrapper: MetaMaskProvider }); expect(result.current.status).toEqual("unavailable"); @@ -65,13 +74,13 @@ describe("MetaMask provider", () => { describe("when MetaMask is available", () => { let originalEth: any; - const { provider: ethereum, testingUtils } = setupEthTesting({ + const testingUtils = generateTestingUtils({ providerType: "MetaMask", }); beforeAll(() => { originalEth = (window as any).ethereum; - (window as any).ethereum = ethereum; + (window as any).ethereum = testingUtils.getProvider(); }); afterAll(() => { diff --git a/src/__tests__/use-metamask.test.tsx b/src/__tests__/use-metamask.test.tsx index d990f54..3f39725 100644 --- a/src/__tests__/use-metamask.test.tsx +++ b/src/__tests__/use-metamask.test.tsx @@ -1,5 +1,5 @@ import { renderHook } from "@testing-library/react-hooks"; -import { setupEthTesting } from "eth-testing"; +import { generateTestingUtils } from "eth-testing"; import { MetaMaskProvider } from ".."; import { useConnectedMetaMask, useMetaMask } from "../use-metamask"; @@ -37,12 +37,12 @@ describe("useMetaMask", () => { }); test("calling useConnectedMetaMask when the status is `connected should return the expected values", async () => { - const { provider: ethereum, testingUtils } = setupEthTesting({ + const testingUtils = generateTestingUtils({ providerType: "MetaMask", }); let originalEth = (window as any).ethereum; - (window as any).ethereum = ethereum; + (window as any).ethereum = testingUtils.getProvider(); testingUtils.mockConnectedWallet([ "0x19F7Fa0a30d5829acBD9B35bA2253a759a37EfC6", @@ -63,10 +63,12 @@ describe("useMetaMask", () => { await waitForNextUpdate(); - expect(result.current.account).toEqual( + const current = result.current as { account: string; chainId: string }; + + expect(current.account).toEqual( "0x19F7Fa0a30d5829acBD9B35bA2253a759a37EfC6" ); - expect(result.current.chainId).toEqual("0x1"); + expect(current.chainId).toEqual("0x1"); (window as any).ethereum = originalEth; }); diff --git a/src/metamask-provider.tsx b/src/metamask-provider.tsx index 1801bdf..d639fdb 100644 --- a/src/metamask-provider.tsx +++ b/src/metamask-provider.tsx @@ -18,10 +18,34 @@ const ERROR_CODE_REQUEST_PENDING = -32002; type WindowInstanceWithEthereum = Window & typeof globalThis & { ethereum?: any }; -async function synchronize(dispatch: (action: Action) => void) { +function getMetaMaskProvider() { const ethereum = (window as WindowInstanceWithEthereum).ethereum; - const isMetaMaskAvailable = Boolean(ethereum) && ethereum.isMetaMask; - if (!isMetaMaskAvailable) { + if (!ethereum) return null; + // The `providers` field is populated when CoinBase Wallet extension is also installed + // The expected object is an array of providers, the MetaMask provider is inside + // See https://docs.cloud.coinbase.com/wallet-sdk/docs/injected-provider-guidance for more information + if (Array.isArray(ethereum.providers)) { + const metaMaskProvider = ethereum.providers.find((p: any) => p.isMetaMask); + if (!metaMaskProvider) return null; + return metaMaskProvider; + } + if (!ethereum.isMetaMask) return null; + return ethereum; +} + +function getSafeMetaMaskProvider() { + const ethereum = getMetaMaskProvider(); + if (!ethereum) { + throw new Error( + "MetaMask provider must be present in order to use this method" + ); + } + return ethereum; +} + +async function synchronize(dispatch: (action: Action) => void) { + const ethereum = getMetaMaskProvider(); + if (!ethereum) { dispatch({ type: "metaMaskUnavailable" }); return; } @@ -45,7 +69,7 @@ async function synchronize(dispatch: (action: Action) => void) { } function subsribeToAccountsChanged(dispatch: (action: Action) => void) { - const ethereum = (window as WindowInstanceWithEthereum).ethereum; + const ethereum = getSafeMetaMaskProvider(); const onAccountsChanged = (accounts: string[]) => dispatch({ type: "metaMaskAccountsChanged", payload: accounts }); ethereum.on("accountsChanged", onAccountsChanged); @@ -55,7 +79,7 @@ function subsribeToAccountsChanged(dispatch: (action: Action) => void) { } function subscribeToChainChanged(dispatch: (action: Action) => void) { - const ethereum = (window as WindowInstanceWithEthereum).ethereum; + const ethereum = getSafeMetaMaskProvider(); const onChainChanged = (chainId: string) => dispatch({ type: "metaMaskChainChanged", payload: chainId }); ethereum.on("chainChanged", onChainChanged); @@ -67,7 +91,7 @@ function subscribeToChainChanged(dispatch: (action: Action) => void) { function requestAccounts( dispatch: (action: Action) => void ): Promise { - const ethereum = (window as WindowInstanceWithEthereum).ethereum; + const ethereum = getSafeMetaMaskProvider(); dispatch({ type: "metaMaskConnecting" }); @@ -119,7 +143,7 @@ function requestAccounts( } async function addEthereumChain(parameters: AddEthereumChainParameter) { - const ethereum = (window as WindowInstanceWithEthereum).ethereum; + const ethereum = getSafeMetaMaskProvider(); try { await ethereum.request({ method: "wallet_addEthereumChain", @@ -134,7 +158,7 @@ async function addEthereumChain(parameters: AddEthereumChainParameter) { } async function switchEthereumChain(chainId: string) { - const ethereum = (window as WindowInstanceWithEthereum).ethereum; + const ethereum = getSafeMetaMaskProvider(); try { await ethereum.request({ method: "wallet_switchEthereumChain", @@ -223,9 +247,7 @@ export function MetaMaskProvider(props: any) { connect, addChain, switchChain, - ethereum: isAvailable - ? (window as WindowInstanceWithEthereum).ethereum - : null, + ethereum: isAvailable ? getSafeMetaMaskProvider() : null, }), [connect, addChain, switchChain, state, isAvailable] );