From 5caaaf1b1f98a8ba0462ef93b002a7cfe5922e9a Mon Sep 17 00:00:00 2001 From: Zach Pomerantz Date: Wed, 21 Jun 2023 12:15:03 -0500 Subject: [PATCH] test(e2e): switching network (#6662) * test(e2e): switching network Co-authored-by: Jordan Frankfurt < * fix: reconnect after failed chain switch * test(e2e): add forks to hardhat.config * test(e2e): wait on wallet_switchEthereumChain * build: upgrade cypress-hardhat * fix: do not disconnect whilst switching * fix: unit tests * fix: better chain selector check * test(e2e): better helper fn naming * test(e2e): better stub naming * fix: doc re-activation * fix: add back click * build: upgrade cypress-hardhat to include network caching * unwrap web3status --------- Co-authored-by: Jordan Frankfurt < --- .../wallet-connection/switch-network.test.ts | 121 +++++++++++++++ hardhat.config.js | 25 ++- package.json | 2 +- .../AccountDrawer/AuthenticatedHeader.tsx | 2 +- .../MiniPortfolio/Pools/index.tsx | 5 +- src/components/Identicon/StatusIcon.test.tsx | 12 +- src/components/Identicon/StatusIcon.tsx | 10 +- .../__snapshots__/StatusIcon.test.tsx.snap | 142 ------------------ src/components/Identicon/index.tsx | 4 +- src/components/NavBar/ChainSelectorRow.tsx | 1 - src/components/Web3Status/index.tsx | 19 ++- src/hooks/useLast.ts | 7 +- src/hooks/useSelectChain.ts | 15 +- src/hooks/useSwitchChain.ts | 76 ++++++++++ src/nft/components/bag/BagFooter.tsx | 4 +- src/pages/Swap/index.tsx | 10 +- src/state/wallets/reducer.test.ts | 54 ++++--- src/state/wallets/reducer.ts | 15 +- src/utils/switchChain.ts | 49 ------ yarn.lock | 8 +- 20 files changed, 321 insertions(+), 260 deletions(-) create mode 100644 cypress/e2e/wallet-connection/switch-network.test.ts create mode 100644 src/hooks/useSwitchChain.ts delete mode 100644 src/utils/switchChain.ts diff --git a/cypress/e2e/wallet-connection/switch-network.test.ts b/cypress/e2e/wallet-connection/switch-network.test.ts new file mode 100644 index 0000000000..3d8d02fcdb --- /dev/null +++ b/cypress/e2e/wallet-connection/switch-network.test.ts @@ -0,0 +1,121 @@ +import { createDeferredPromise } from '../../../src/test-utils/promise' +import { getTestSelector } from '../../utils' + +function waitsForActiveChain(chain: string) { + cy.get(getTestSelector('chain-selector-logo')).invoke('attr', 'alt').should('eq', chain) +} + +function switchChain(chain: string) { + cy.get(getTestSelector('chain-selector')).eq(1).click() + cy.contains(chain).click() +} + +describe('network switching', () => { + beforeEach(() => { + cy.visit('/swap', { ethereum: 'hardhat' }) + cy.get(getTestSelector('web3-status-connected')) + }) + + function rejectsNetworkSwitchWith(rejection: unknown) { + cy.hardhat().then((hardhat) => { + // Reject network switch + const sendStub = cy.stub(hardhat.provider, 'send').log(false).as('switch') + sendStub.withArgs('wallet_switchEthereumChain').rejects(rejection) + sendStub.callThrough() // allows other calls to return non-stubbed values + }) + + switchChain('Polygon') + + // Verify rejected network switch + cy.get('@switch').should('have.been.calledWith', 'wallet_switchEthereumChain') + waitsForActiveChain('Ethereum') + cy.get(getTestSelector('web3-status-connected')) + } + + it('should not display message on user rejection', () => { + const USER_REJECTION = { code: 4001 } + rejectsNetworkSwitchWith(USER_REJECTION) + cy.get(getTestSelector('popups')).should('not.contain', 'Failed to switch networks') + }) + + it('should display message on unknown error', () => { + rejectsNetworkSwitchWith(new Error('Unknown error')) + cy.get(getTestSelector('popups')).contains('Failed to switch networks') + }) + + it('should add missing chain', () => { + cy.hardhat().then((hardhat) => { + // https://docs.metamask.io/guide/rpc-api.html#unrestricted-methods + const CHAIN_NOT_ADDED = { code: 4902 } // missing message in useSelectChain + + // Reject network switch with CHAIN_NOT_ADDED + const sendStub = cy.stub(hardhat.provider, 'send').log(false).as('switch') + let added = false + sendStub + .withArgs('wallet_switchEthereumChain') + .callsFake(() => (added ? Promise.resolve(null) : Promise.reject(CHAIN_NOT_ADDED))) + sendStub.withArgs('wallet_addEthereumChain').callsFake(() => { + added = true + return Promise.resolve(null) + }) + sendStub.callThrough() // allows other calls to return non-stubbed values + }) + + switchChain('Polygon') + + // Verify the network was added + cy.get('@switch').should('have.been.calledWith', 'wallet_switchEthereumChain') + cy.get('@switch').should('have.been.calledWith', 'wallet_addEthereumChain', [ + { + blockExplorerUrls: ['https://polygonscan.com/'], + chainId: '0x89', + chainName: 'Polygon', + nativeCurrency: { name: 'Polygon Matic', symbol: 'MATIC', decimals: 18 }, + rpcUrls: ['https://polygon-rpc.com/'], + }, + ]) + }) + + it('should not disconnect while switching', () => { + const promise = createDeferredPromise() + + cy.hardhat().then((hardhat) => { + // Reject network switch with CHAIN_NOT_ADDED + const sendStub = cy.stub(hardhat.provider, 'send').log(false).as('switch') + sendStub.withArgs('wallet_switchEthereumChain').returns(promise) + sendStub.callThrough() // allows other calls to return non-stubbed values + }) + + switchChain('Polygon') + + // Verify there is no disconnection + cy.get('@switch').should('have.been.calledWith', 'wallet_switchEthereumChain') + cy.contains('Connecting to Polygon') + cy.get(getTestSelector('web3-status-connected')).should('be.disabled') + promise.resolve() + }) + + it('should switch networks', () => { + // Select an output currency + cy.get('#swap-currency-output .open-currency-select-button').click() + cy.contains('USDC').click() + + // Populate input/output fields + cy.get('#swap-currency-input .token-amount-input').clear().type('1') + cy.get('#swap-currency-output .token-amount-input').should('not.equal', '') + + // Switch network + switchChain('Polygon') + + // Verify network switch + cy.wait('@wallet_switchEthereumChain') + waitsForActiveChain('Polygon') + cy.get(getTestSelector('web3-status-connected')) + + // Verify that the input/output fields were reset + cy.get('#swap-currency-input .token-amount-input').should('have.value', '') + cy.get(`#swap-currency-input .token-symbol-container`).should('contain.text', 'MATIC') + cy.get(`#swap-currency-output .token-amount-input`).should('not.have.value') + cy.get(`#swap-currency-output .token-symbol-container`).should('contain.text', 'Select token') + }) +}) diff --git a/hardhat.config.js b/hardhat.config.js index e37f72c79c..31a2e028a2 100644 --- a/hardhat.config.js +++ b/hardhat.config.js @@ -1,3 +1,5 @@ +import { SupportedChainId } from '@uniswap/sdk-core' + /* eslint-env node */ require('dotenv').config() @@ -5,20 +7,33 @@ require('dotenv').config() // The only requirement is that all infrastructure under test (eg Permit2 contracts) are already deployed. // TODO(WEB-2187): Make more dynamic to avoid manually updating const BLOCK_NUMBER = 17388567 +const POLYGON_BLOCK_NUMBER = 43600000 -const mainnetFork = { - url: `https://mainnet.infura.io/v3/${process.env.REACT_APP_INFURA_KEY}`, - blockNumber: BLOCK_NUMBER, +const forkingConfig = { httpHeaders: { Origin: 'localhost:3000', // infura allowlists requests by origin }, } +const forks = { + [SupportedChainId.MAINNET]: { + url: `https://mainnet.infura.io/v3/${process.env.REACT_APP_INFURA_KEY}`, + blockNumber: BLOCK_NUMBER, + ...forkingConfig, + }, + [SupportedChainId.POLYGON]: { + url: `https://polygon-mainnet.infura.io/v3/${process.env.REACT_APP_INFURA_KEY}`, + blockNumber: POLYGON_BLOCK_NUMBER, + ...forkingConfig, + }, +} + module.exports = { + forks, networks: { hardhat: { - chainId: 1, - forking: mainnetFork, + chainId: SupportedChainId.MAINNET, + forking: forks[SupportedChainId.MAINNET], accounts: { count: 2, }, diff --git a/package.json b/package.json index 76b539519d..2d76f96a9b 100644 --- a/package.json +++ b/package.json @@ -106,7 +106,7 @@ "buffer": "^6.0.3", "concurrently": "^8.0.1", "cypress": "12.12.0", - "cypress-hardhat": "^2.3.0", + "cypress-hardhat": "^2.4.1", "env-cmd": "^10.1.0", "eslint": "^7.11.0", "eslint-plugin-import": "^2.27", diff --git a/src/components/AccountDrawer/AuthenticatedHeader.tsx b/src/components/AccountDrawer/AuthenticatedHeader.tsx index 6979e5a23a..610cf85b00 100644 --- a/src/components/AccountDrawer/AuthenticatedHeader.tsx +++ b/src/components/AccountDrawer/AuthenticatedHeader.tsx @@ -240,7 +240,7 @@ export default function AuthenticatedHeader({ account, openSettings }: { account - + {account && ( diff --git a/src/components/AccountDrawer/MiniPortfolio/Pools/index.tsx b/src/components/AccountDrawer/MiniPortfolio/Pools/index.tsx index 38d07b3ff4..db9cd8c309 100644 --- a/src/components/AccountDrawer/MiniPortfolio/Pools/index.tsx +++ b/src/components/AccountDrawer/MiniPortfolio/Pools/index.tsx @@ -8,12 +8,12 @@ import { useToggleAccountDrawer } from 'components/AccountDrawer' import Row from 'components/Row' import { MouseoverTooltip } from 'components/Tooltip' import { useFilterPossiblyMaliciousPositions } from 'hooks/useFilterPossiblyMaliciousPositions' +import { useSwitchChain } from 'hooks/useSwitchChain' import { EmptyWalletModule } from 'nft/components/profile/view/EmptyWalletContent' import { useCallback, useMemo, useReducer } from 'react' import { useNavigate } from 'react-router-dom' import styled from 'styled-components/macro' import { ThemedText } from 'theme' -import { switchChain } from 'utils/switchChain' import { ExpandoRow } from '../ExpandoRow' import { PortfolioLogo } from '../PortfolioLogo' @@ -126,11 +126,12 @@ function PositionListItem({ positionInfo }: { positionInfo: PositionInfo }) { const navigate = useNavigate() const toggleWalletDrawer = useToggleAccountDrawer() const { chainId: walletChainId, connector } = useWeb3React() + const switchChain = useSwitchChain() const onClick = useCallback(async () => { if (walletChainId !== chainId) await switchChain(connector, chainId) toggleWalletDrawer() navigate('/pool/' + details.tokenId) - }, [walletChainId, chainId, connector, toggleWalletDrawer, navigate, details.tokenId]) + }, [walletChainId, chainId, switchChain, connector, toggleWalletDrawer, navigate, details.tokenId]) const analyticsEventProperties = useMemo( () => ({ chain_id: chainId, diff --git a/src/components/Identicon/StatusIcon.test.tsx b/src/components/Identicon/StatusIcon.test.tsx index f0bc6b0bda..364cb38bdf 100644 --- a/src/components/Identicon/StatusIcon.test.tsx +++ b/src/components/Identicon/StatusIcon.test.tsx @@ -5,6 +5,8 @@ import { render } from 'test-utils/render' import StatusIcon from './StatusIcon' +const ACCOUNT = '0x0' + jest.mock('../../hooks/useSocksBalance', () => ({ useHasSocks: () => true, })) @@ -14,14 +16,14 @@ describe('StatusIcon', () => { it('renders children in correct order', () => { const supportedConnections = getConnections() const injectedConnection = supportedConnections[1] - const component = render() + const component = render() expect(component.getByTestId('StatusIconRoot')).toMatchSnapshot() }) it('renders without mini icons', () => { const supportedConnections = getConnections() const injectedConnection = supportedConnections[1] - const component = render() + const component = render() expect(component.getByTestId('StatusIconRoot').children.length).toEqual(0) }) }) @@ -37,15 +39,15 @@ describe('StatusIcon', () => { it('renders children in correct order', () => { const supportedConnections = getConnections() const injectedConnection = supportedConnections[1] - const component = render() + const component = render() expect(component.getByTestId('StatusIconRoot')).toMatchSnapshot() }) it('renders without mini icons', () => { const supportedConnections = getConnections() const injectedConnection = supportedConnections[1] - const component = render() - expect(component.getByTestId('StatusIconRoot').children.length).toEqual(1) + const component = render() + expect(component.getByTestId('StatusIconRoot').children.length).toEqual(0) }) }) }) diff --git a/src/components/Identicon/StatusIcon.tsx b/src/components/Identicon/StatusIcon.tsx index 94021d7f9c..06de0397e9 100644 --- a/src/components/Identicon/StatusIcon.tsx +++ b/src/components/Identicon/StatusIcon.tsx @@ -1,4 +1,3 @@ -import { useWeb3React } from '@web3-react/core' import { Unicon } from 'components/Unicon' import { Connection, ConnectionType } from 'connection/types' import useENSAvatar from 'hooks/useENSAvatar' @@ -67,24 +66,25 @@ const MiniWalletIcon = ({ connection, side }: { connection: Connection; side: 'l ) } -const MainWalletIcon = ({ connection, size }: { connection: Connection; size: number }) => { - const { account } = useWeb3React() +const MainWalletIcon = ({ account, connection, size }: { account: string; connection: Connection; size: number }) => { const { avatar } = useENSAvatar(account ?? undefined) if (!account) { return null } else if (avatar || (connection.type === ConnectionType.INJECTED && connection.getName() === 'MetaMask')) { - return + return } else { return } } export default function StatusIcon({ + account, connection, size = 16, showMiniIcons = true, }: { + account: string connection: Connection size?: number showMiniIcons?: boolean @@ -93,7 +93,7 @@ export default function StatusIcon({ return ( - + {showMiniIcons && } {hasSocks && showMiniIcons && } diff --git a/src/components/Identicon/__snapshots__/StatusIcon.test.tsx.snap b/src/components/Identicon/__snapshots__/StatusIcon.test.tsx.snap index 7ca05073eb..9f9ed1c083 100644 --- a/src/components/Identicon/__snapshots__/StatusIcon.test.tsx.snap +++ b/src/components/Identicon/__snapshots__/StatusIcon.test.tsx.snap @@ -108,148 +108,6 @@ exports[`StatusIcon with account renders children in correct order 1`] = ` data-testid="StatusIconRoot" size="16" > -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
diff --git a/src/components/Identicon/index.tsx b/src/components/Identicon/index.tsx index bc0e40aca5..bb735c0e70 100644 --- a/src/components/Identicon/index.tsx +++ b/src/components/Identicon/index.tsx @@ -1,5 +1,4 @@ import jazzicon from '@metamask/jazzicon' -import { useWeb3React } from '@web3-react/core' import useENSAvatar from 'hooks/useENSAvatar' import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react' import styled from 'styled-components/macro' @@ -18,8 +17,7 @@ const StyledAvatar = styled.img` border-radius: inherit; ` -export default function Identicon({ size }: { size?: number }) { - const { account } = useWeb3React() +export default function Identicon({ account, size }: { account: string; size?: number }) { const { avatar } = useENSAvatar(account ?? undefined) const [fetchable, setFetchable] = useState(true) const iconSize = size ?? 24 diff --git a/src/components/NavBar/ChainSelectorRow.tsx b/src/components/NavBar/ChainSelectorRow.tsx index 6f80161493..561a0dd88f 100644 --- a/src/components/NavBar/ChainSelectorRow.tsx +++ b/src/components/NavBar/ChainSelectorRow.tsx @@ -80,7 +80,6 @@ export default function ChainSelectorRow({ disabled, targetChain, onSelectChain, onClick={() => { if (!disabled) onSelectChain(targetChain) }} - data-testid={`chain-selector-option-${label.toLowerCase()}`} > diff --git a/src/components/Web3Status/index.tsx b/src/components/Web3Status/index.tsx index 705d3e8295..d2203d99d5 100644 --- a/src/components/Web3Status/index.tsx +++ b/src/components/Web3Status/index.tsx @@ -7,11 +7,13 @@ import PrefetchBalancesWrapper from 'components/AccountDrawer/PrefetchBalancesWr import Loader from 'components/Icons/LoadingSpinner' import { IconWrapper } from 'components/Identicon/StatusIcon' import { getConnection } from 'connection' +import useLast from 'hooks/useLast' import { navSearchInputVisibleSize } from 'hooks/useScreenSize' import { Portal } from 'nft/components/common/Portal' import { useIsNftClaimAvailable } from 'nft/hooks/useIsNftClaimAvailable' import { darken } from 'polished' import { useCallback, useMemo } from 'react' +import { useAppSelector } from 'state/hooks' import styled from 'styled-components/macro' import { colors } from 'theme/colors' import { flexRowNoWrap } from 'theme/styles' @@ -42,7 +44,7 @@ const Web3StatusGeneric = styled(ButtonSecondary)` } ` -const Web3StatusConnectWrapper = styled.div<{ faded?: boolean }>` +const Web3StatusConnectWrapper = styled.div` ${flexRowNoWrap}; align-items: center; background-color: ${({ theme }) => theme.accentActionSoft}; @@ -130,8 +132,11 @@ const StyledConnectButton = styled.button` ` function Web3StatusInner() { - const { account, connector, chainId, ENSName } = useWeb3React() + const switchingChain = useAppSelector((state) => state.wallets.switchingChain) + const ignoreWhileSwitchingChain = useCallback(() => !switchingChain, [switchingChain]) + const { account, connector, ENSName } = useLast(useWeb3React(), ignoreWhileSwitchingChain) const connection = getConnection(connector) + const [, toggleAccountDrawer] = useAccountDrawer() const handleWalletDropdownClick = useCallback(() => { sendAnalyticsEvent(InterfaceEventName.ACCOUNT_DROPDOWN_BUTTON_CLICKED) @@ -150,9 +155,7 @@ function Web3StatusInner() { const hasPendingTransactions = !!pending.length - if (!chainId) { - return null - } else if (account) { + if (account) { return ( - {!hasPendingTransactions && } + {!hasPendingTransactions && ( + + )} {hasPendingTransactions ? ( @@ -190,7 +196,6 @@ function Web3StatusInner() { > e.key === 'Enter' && handleWalletDropdownClick()} onClick={handleWalletDropdownClick} > diff --git a/src/hooks/useLast.ts b/src/hooks/useLast.ts index 33f169bd22..c12d2c4590 100644 --- a/src/hooks/useLast.ts +++ b/src/hooks/useLast.ts @@ -5,11 +5,8 @@ import { useEffect, useState } from 'react' * @param value changing value * @param filterFn function that determines whether a given value should be considered for the last value */ -export default function useLast( - value: T | undefined | null, - filterFn?: (value: T | null | undefined) => boolean -): T | null | undefined { - const [last, setLast] = useState(filterFn && filterFn(value) ? value : undefined) +export default function useLast(value: T, filterFn?: (value: T) => boolean): T { + const [last, setLast] = useState(value) useEffect(() => { setLast((last) => { const shouldUse: boolean = filterFn ? filterFn(value) : true diff --git a/src/hooks/useSelectChain.ts b/src/hooks/useSelectChain.ts index 52191eba28..67d399e701 100644 --- a/src/hooks/useSelectChain.ts +++ b/src/hooks/useSelectChain.ts @@ -5,11 +5,13 @@ import { SupportedChainId } from 'constants/chains' import { useCallback } from 'react' import { addPopup } from 'state/application/reducer' import { useAppDispatch } from 'state/hooks' -import { switchChain } from 'utils/switchChain' + +import { useSwitchChain } from './useSwitchChain' export default function useSelectChain() { const dispatch = useAppDispatch() const { connector } = useWeb3React() + const switchChain = useSwitchChain() return useCallback( async (targetChain: SupportedChainId) => { @@ -20,15 +22,12 @@ export default function useSelectChain() { try { await switchChain(connector, targetChain) } catch (error) { - if (didUserReject(connection, error)) { - return + if (!didUserReject(connection, error)) { + console.error('Failed to switch networks', error) + dispatch(addPopup({ content: { failedSwitchNetwork: targetChain }, key: 'failed-network-switch' })) } - - console.error('Failed to switch networks', error) - - dispatch(addPopup({ content: { failedSwitchNetwork: targetChain }, key: 'failed-network-switch' })) } }, - [connector, dispatch] + [connector, dispatch, switchChain] ) } diff --git a/src/hooks/useSwitchChain.ts b/src/hooks/useSwitchChain.ts new file mode 100644 index 0000000000..5dfafd0c40 --- /dev/null +++ b/src/hooks/useSwitchChain.ts @@ -0,0 +1,76 @@ +import { Connector } from '@web3-react/types' +import { + networkConnection, + uniwalletConnectConnection, + walletConnectV1Connection, + walletConnectV2Connection, +} from 'connection' +import { getChainInfo } from 'constants/chainInfo' +import { isSupportedChain, SupportedChainId } from 'constants/chains' +import { FALLBACK_URLS, RPC_URLS } from 'constants/networks' +import { useCallback } from 'react' +import { useAppDispatch } from 'state/hooks' +import { endSwitchingChain, startSwitchingChain } from 'state/wallets/reducer' + +function getRpcUrl(chainId: SupportedChainId): string { + switch (chainId) { + case SupportedChainId.MAINNET: + case SupportedChainId.GOERLI: + case SupportedChainId.SEPOLIA: + return RPC_URLS[chainId][0] + // Attempting to add a chain using an infura URL will not work, as the URL will be unreachable from the MetaMask background page. + // MetaMask allows switching to any publicly reachable URL, but for novel chains, it will display a warning if it is not on the "Safe" list. + // See the definition of FALLBACK_URLS for more details. + default: + return FALLBACK_URLS[chainId][0] + } +} + +export function useSwitchChain() { + const dispatch = useAppDispatch() + + return useCallback( + async (connector: Connector, chainId: SupportedChainId) => { + if (!isSupportedChain(chainId)) { + throw new Error(`Chain ${chainId} not supported for connector (${typeof connector})`) + } else { + dispatch(startSwitchingChain(chainId)) + try { + if ( + [ + walletConnectV1Connection.connector, + walletConnectV2Connection.connector, + uniwalletConnectConnection.connector, + networkConnection.connector, + ].includes(connector) + ) { + await connector.activate(chainId) + } else { + const info = getChainInfo(chainId) + const addChainParameter = { + chainId, + chainName: info.label, + rpcUrls: [getRpcUrl(chainId)], + nativeCurrency: info.nativeCurrency, + blockExplorerUrls: [info.explorer], + } + await connector.activate(addChainParameter) + } + } catch (error) { + // In activating a new chain, the connector passes through a deactivated state. + // If we fail to switch chains, it may remain in this state, and no longer be usable. + // We defensively re-activate the connector to ensure the user does not notice any change. + try { + await connector.activate() + } catch (error) { + console.error('Failed to re-activate connector', error) + } + throw error + } finally { + dispatch(endSwitchingChain()) + } + } + }, + [dispatch] + ) +} diff --git a/src/nft/components/bag/BagFooter.tsx b/src/nft/components/bag/BagFooter.tsx index 255f5dae55..414361a153 100644 --- a/src/nft/components/bag/BagFooter.tsx +++ b/src/nft/components/bag/BagFooter.tsx @@ -18,6 +18,7 @@ import { useNftUniversalRouterAddress } from 'graphql/data/nft/NftUniversalRoute import { useCurrency } from 'hooks/Tokens' import { AllowanceState } from 'hooks/usePermit2Allowance' import { useStablecoinValue } from 'hooks/useStablecoinPrice' +import { useSwitchChain } from 'hooks/useSwitchChain' import { useTokenBalance } from 'lib/hooks/useCurrencyBalance' import tryParseCurrencyAmount from 'lib/utils/tryParseCurrencyAmount' import { useBag } from 'nft/hooks/useBag' @@ -37,7 +38,6 @@ import { AlertTriangle, ChevronDown } from 'react-feather' import { InterfaceTrade, TradeState } from 'state/routing/types' import styled, { useTheme } from 'styled-components/macro' import { ThemedText } from 'theme' -import { switchChain } from 'utils/switchChain' import { shallow } from 'zustand/shallow' import { BuyButtonStateData, BuyButtonStates, getBuyButtonStateData } from './ButtonStates' @@ -348,6 +348,7 @@ export const BagFooter = ({ setModalIsOpen, eventProperties }: BagFooterProps) = setBagStatus(BagStatus.ADDING_TO_BAG) }, [inputCurrency, setBagStatus]) + const switchChain = useSwitchChain() const { buttonText, buttonTextColor, @@ -441,6 +442,7 @@ export const BagFooter = ({ setModalIsOpen, eventProperties }: BagFooterProps) = priceImpact, theme, fetchAssets, + switchChain, connector, toggleWalletDrawer, setBagExpanded, diff --git a/src/pages/Swap/index.tsx b/src/pages/Swap/index.tsx index 040ea6cce6..a056d4cbdd 100644 --- a/src/pages/Swap/index.tsx +++ b/src/pages/Swap/index.tsx @@ -26,6 +26,7 @@ import { useMaxAmountIn } from 'hooks/useMaxAmountIn' import usePermit2Allowance, { AllowanceState } from 'hooks/usePermit2Allowance' import usePrevious from 'hooks/usePrevious' import { useSwapCallback } from 'hooks/useSwapCallback' +import { useSwitchChain } from 'hooks/useSwitchChain' import { useUSDPrice } from 'hooks/useUSDPrice' import JSBI from 'jsbi' import { formatSwapQuoteReceivedEventProperties } from 'lib/utils/analytics' @@ -33,11 +34,11 @@ import { ReactNode, useCallback, useEffect, useMemo, useReducer, useState } from import { ArrowDown } from 'react-feather' import { useLocation, useNavigate } from 'react-router-dom' import { Text } from 'rebass' +import { useAppSelector } from 'state/hooks' import { InterfaceTrade, TradeState } from 'state/routing/types' import styled, { useTheme } from 'styled-components/macro' import { currencyAmountToPreciseFloat, formatTransactionAmount } from 'utils/formatNumbers' import { didUserReject } from 'utils/swapErrorToUserReadableMessage' -import { switchChain } from 'utils/switchChain' import AddressInputPanel from '../../components/AddressInputPanel' import { ButtonError, ButtonLight, ButtonPrimary } from '../../components/Button' @@ -536,6 +537,9 @@ export function Swap({ !showWrap && userHasSpecifiedInputOutput && (trade || routeIsLoading || routeIsSyncing) ) + const switchChain = useSwitchChain() + const switchingChain = useAppSelector((state) => state.wallets.switchingChain) + return ( Unsupported Asset + ) : switchingChain ? ( + + Connecting to {getChainInfo(switchingChain)?.label} + ) : !account ? ( { - it('should add a connected wallet', () => { - const initialState = { connectedWallets: [] } - const action = { - type: 'wallets/addConnectedWallet', - payload: WALLET, - } - const expectedState = { connectedWallets: [WALLET] } - expect(walletsReducer(initialState, action)).toEqual(expectedState) +const INITIAL_STATE = { connectedWallets: [] as Wallet[], switchingChain: false as const } + +describe('wallets reducer', () => { + describe('connectedWallets', () => { + it('should add a connected wallet', () => { + const action = { + type: 'wallets/addConnectedWallet', + payload: WALLET, + } + const expectedState = { connectedWallets: [WALLET], switchingChain: false } + expect(walletsReducer(INITIAL_STATE, action)).toEqual(expectedState) + }) + + it('should not duplicate a connected wallet', () => { + const action = { + type: 'wallets/addConnectedWallet', + payload: WALLET, + } + const expectedState = { connectedWallets: [WALLET], switchingChain: false } + expect(walletsReducer({ ...INITIAL_STATE, connectedWallets: [WALLET] }, action)).toEqual(expectedState) + }) }) - it('should not duplicate a connected wallet', () => { - const initialState = { connectedWallets: [WALLET] } - const action = { - type: 'wallets/addConnectedWallet', - payload: WALLET, - } - const expectedState = { connectedWallets: [WALLET] } - expect(walletsReducer(initialState, action)).toEqual(expectedState) + describe('switchingChain', () => { + it('should start switching to chain', () => { + const action = { + type: 'wallets/startSwitchingChain', + payload: 1, + } + const expectedState = { connectedWallets: [], switchingChain: 1 } + expect(walletsReducer(INITIAL_STATE, action)).toEqual(expectedState) + }) + + it('should stop switching to chain', () => { + const action = { + type: 'wallets/endSwitchingChain', + } + expect(walletsReducer({ ...INITIAL_STATE, switchingChain: 1 }, action)).toEqual(INITIAL_STATE) + }) }) }) diff --git a/src/state/wallets/reducer.ts b/src/state/wallets/reducer.ts index cc7cc3d3af..64ba725952 100644 --- a/src/state/wallets/reducer.ts +++ b/src/state/wallets/reducer.ts @@ -1,16 +1,19 @@ import { createSlice } from '@reduxjs/toolkit' +import { SupportedChainId } from 'constants/chains' import { shallowEqual } from 'react-redux' import { Wallet } from './types' -// Used to track wallets that have been connected by the user in current session, and remove them when deliberately disconnected. -// Used to compute is_reconnect event property for analytics interface WalletState { + // Used to track wallets that have been connected by the user in current session, and remove them when deliberately disconnected. + // Used to compute is_reconnect event property for analytics connectedWallets: Wallet[] + switchingChain: SupportedChainId | false } const initialState: WalletState = { connectedWallets: [], + switchingChain: false, } const walletsSlice = createSlice({ @@ -21,8 +24,14 @@ const walletsSlice = createSlice({ if (state.connectedWallets.some((wallet) => shallowEqual(payload, wallet))) return state.connectedWallets = [...state.connectedWallets, payload] }, + startSwitchingChain(state, { payload }) { + state.switchingChain = payload + }, + endSwitchingChain(state) { + state.switchingChain = false + }, }, }) -export const { addConnectedWallet } = walletsSlice.actions +export const { addConnectedWallet, startSwitchingChain, endSwitchingChain } = walletsSlice.actions export default walletsSlice.reducer diff --git a/src/utils/switchChain.ts b/src/utils/switchChain.ts deleted file mode 100644 index 83be8510db..0000000000 --- a/src/utils/switchChain.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Connector } from '@web3-react/types' -import { - networkConnection, - uniwalletConnectConnection, - walletConnectV1Connection, - walletConnectV2Connection, -} from 'connection' -import { getChainInfo } from 'constants/chainInfo' -import { isSupportedChain, SupportedChainId } from 'constants/chains' -import { FALLBACK_URLS, RPC_URLS } from 'constants/networks' - -function getRpcUrl(chainId: SupportedChainId): string { - switch (chainId) { - case SupportedChainId.MAINNET: - case SupportedChainId.GOERLI: - case SupportedChainId.SEPOLIA: - return RPC_URLS[chainId][0] - // Attempting to add a chain using an infura URL will not work, as the URL will be unreachable from the MetaMask background page. - // MetaMask allows switching to any publicly reachable URL, but for novel chains, it will display a warning if it is not on the "Safe" list. - // See the definition of FALLBACK_URLS for more details. - default: - return FALLBACK_URLS[chainId][0] - } -} - -export const switchChain = async (connector: Connector, chainId: SupportedChainId) => { - if (!isSupportedChain(chainId)) { - throw new Error(`Chain ${chainId} not supported for connector (${typeof connector})`) - } else if ( - [ - walletConnectV1Connection.connector, - walletConnectV2Connection.connector, - uniwalletConnectConnection.connector, - networkConnection.connector, - ].includes(connector) - ) { - await connector.activate(chainId) - } else { - const info = getChainInfo(chainId) - const addChainParameter = { - chainId, - chainName: info.label, - rpcUrls: [getRpcUrl(chainId)], - nativeCurrency: info.nativeCurrency, - blockExplorerUrls: [info.explorer], - } - await connector.activate(addChainParameter) - } -} diff --git a/yarn.lock b/yarn.lock index dfb5a0eee7..2e64eff413 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9109,10 +9109,10 @@ csstype@^3.0.2, csstype@^3.0.7: resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.0.tgz#4ddcac3718d787cf9df0d1b7d15033925c8f29f2" integrity sha512-uX1KG+x9h5hIJsaKR9xHUeUraxf8IODOwq9JLNPq6BwB04a/xgpq3rcx47l5BZu5zBPlgD342tdke3Hom/nJRA== -cypress-hardhat@^2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/cypress-hardhat/-/cypress-hardhat-2.3.0.tgz#646b35d57490d060e3fd4441e76e4d91b4ff4ec7" - integrity sha512-Sj437lFrUZ9UJGXS5a+DLQPBoyaWUxJafEiydNqKKpViKswBiylHD3ZJu2mrtQ/fhp7lgOPpMP72IQX4Ncwzdg== +cypress-hardhat@^2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/cypress-hardhat/-/cypress-hardhat-2.4.1.tgz#dff41b06a85c4a572d43ae662f2a0cd03c29c4d7" + integrity sha512-D9keayw+9C1YGPTXEfkXDGmPusMoA5Sg2fiRoaBgKHO53UUFQRKnwa6HCTkCxcd0t+Hh8UcwpFwkxtlXh5QtjA== dependencies: "@uniswap/permit2-sdk" "^1.2.0" "@uniswap/sdk-core" "^3.0.1"