test(e2e): switching network (#6662)

* test(e2e): switching network

Co-authored-by: Jordan Frankfurt <<jordan@CORN-Jordan-949.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 <<jordan@CORN-Jordan-949.frankfurt>
This commit is contained in:
Zach Pomerantz 2023-06-21 12:15:03 -05:00 committed by GitHub
parent c0163767ed
commit 5caaaf1b1f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 321 additions and 260 deletions

@ -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')
})
})

@ -1,3 +1,5 @@
import { SupportedChainId } from '@uniswap/sdk-core'
/* eslint-env node */ /* eslint-env node */
require('dotenv').config() 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. // 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 // TODO(WEB-2187): Make more dynamic to avoid manually updating
const BLOCK_NUMBER = 17388567 const BLOCK_NUMBER = 17388567
const POLYGON_BLOCK_NUMBER = 43600000
const mainnetFork = { const forkingConfig = {
url: `https://mainnet.infura.io/v3/${process.env.REACT_APP_INFURA_KEY}`,
blockNumber: BLOCK_NUMBER,
httpHeaders: { httpHeaders: {
Origin: 'localhost:3000', // infura allowlists requests by origin 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 = { module.exports = {
forks,
networks: { networks: {
hardhat: { hardhat: {
chainId: 1, chainId: SupportedChainId.MAINNET,
forking: mainnetFork, forking: forks[SupportedChainId.MAINNET],
accounts: { accounts: {
count: 2, count: 2,
}, },

@ -106,7 +106,7 @@
"buffer": "^6.0.3", "buffer": "^6.0.3",
"concurrently": "^8.0.1", "concurrently": "^8.0.1",
"cypress": "12.12.0", "cypress": "12.12.0",
"cypress-hardhat": "^2.3.0", "cypress-hardhat": "^2.4.1",
"env-cmd": "^10.1.0", "env-cmd": "^10.1.0",
"eslint": "^7.11.0", "eslint": "^7.11.0",
"eslint-plugin-import": "^2.27", "eslint-plugin-import": "^2.27",

@ -240,7 +240,7 @@ export default function AuthenticatedHeader({ account, openSettings }: { account
<AuthenticatedHeaderWrapper> <AuthenticatedHeaderWrapper>
<HeaderWrapper> <HeaderWrapper>
<StatusWrapper> <StatusWrapper>
<StatusIcon connection={connection} size={40} /> <StatusIcon account={account} connection={connection} size={40} />
{account && ( {account && (
<AccountNamesWrapper> <AccountNamesWrapper>
<ThemedText.SubHeader> <ThemedText.SubHeader>

@ -8,12 +8,12 @@ import { useToggleAccountDrawer } from 'components/AccountDrawer'
import Row from 'components/Row' import Row from 'components/Row'
import { MouseoverTooltip } from 'components/Tooltip' import { MouseoverTooltip } from 'components/Tooltip'
import { useFilterPossiblyMaliciousPositions } from 'hooks/useFilterPossiblyMaliciousPositions' import { useFilterPossiblyMaliciousPositions } from 'hooks/useFilterPossiblyMaliciousPositions'
import { useSwitchChain } from 'hooks/useSwitchChain'
import { EmptyWalletModule } from 'nft/components/profile/view/EmptyWalletContent' import { EmptyWalletModule } from 'nft/components/profile/view/EmptyWalletContent'
import { useCallback, useMemo, useReducer } from 'react' import { useCallback, useMemo, useReducer } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import styled from 'styled-components/macro' import styled from 'styled-components/macro'
import { ThemedText } from 'theme' import { ThemedText } from 'theme'
import { switchChain } from 'utils/switchChain'
import { ExpandoRow } from '../ExpandoRow' import { ExpandoRow } from '../ExpandoRow'
import { PortfolioLogo } from '../PortfolioLogo' import { PortfolioLogo } from '../PortfolioLogo'
@ -126,11 +126,12 @@ function PositionListItem({ positionInfo }: { positionInfo: PositionInfo }) {
const navigate = useNavigate() const navigate = useNavigate()
const toggleWalletDrawer = useToggleAccountDrawer() const toggleWalletDrawer = useToggleAccountDrawer()
const { chainId: walletChainId, connector } = useWeb3React() const { chainId: walletChainId, connector } = useWeb3React()
const switchChain = useSwitchChain()
const onClick = useCallback(async () => { const onClick = useCallback(async () => {
if (walletChainId !== chainId) await switchChain(connector, chainId) if (walletChainId !== chainId) await switchChain(connector, chainId)
toggleWalletDrawer() toggleWalletDrawer()
navigate('/pool/' + details.tokenId) navigate('/pool/' + details.tokenId)
}, [walletChainId, chainId, connector, toggleWalletDrawer, navigate, details.tokenId]) }, [walletChainId, chainId, switchChain, connector, toggleWalletDrawer, navigate, details.tokenId])
const analyticsEventProperties = useMemo( const analyticsEventProperties = useMemo(
() => ({ () => ({
chain_id: chainId, chain_id: chainId,

@ -5,6 +5,8 @@ import { render } from 'test-utils/render'
import StatusIcon from './StatusIcon' import StatusIcon from './StatusIcon'
const ACCOUNT = '0x0'
jest.mock('../../hooks/useSocksBalance', () => ({ jest.mock('../../hooks/useSocksBalance', () => ({
useHasSocks: () => true, useHasSocks: () => true,
})) }))
@ -14,14 +16,14 @@ describe('StatusIcon', () => {
it('renders children in correct order', () => { it('renders children in correct order', () => {
const supportedConnections = getConnections() const supportedConnections = getConnections()
const injectedConnection = supportedConnections[1] const injectedConnection = supportedConnections[1]
const component = render(<StatusIcon connection={injectedConnection} />) const component = render(<StatusIcon account={ACCOUNT} connection={injectedConnection} />)
expect(component.getByTestId('StatusIconRoot')).toMatchSnapshot() expect(component.getByTestId('StatusIconRoot')).toMatchSnapshot()
}) })
it('renders without mini icons', () => { it('renders without mini icons', () => {
const supportedConnections = getConnections() const supportedConnections = getConnections()
const injectedConnection = supportedConnections[1] const injectedConnection = supportedConnections[1]
const component = render(<StatusIcon connection={injectedConnection} showMiniIcons={false} />) const component = render(<StatusIcon account={ACCOUNT} connection={injectedConnection} showMiniIcons={false} />)
expect(component.getByTestId('StatusIconRoot').children.length).toEqual(0) expect(component.getByTestId('StatusIconRoot').children.length).toEqual(0)
}) })
}) })
@ -37,15 +39,15 @@ describe('StatusIcon', () => {
it('renders children in correct order', () => { it('renders children in correct order', () => {
const supportedConnections = getConnections() const supportedConnections = getConnections()
const injectedConnection = supportedConnections[1] const injectedConnection = supportedConnections[1]
const component = render(<StatusIcon connection={injectedConnection} />) const component = render(<StatusIcon account={ACCOUNT} connection={injectedConnection} />)
expect(component.getByTestId('StatusIconRoot')).toMatchSnapshot() expect(component.getByTestId('StatusIconRoot')).toMatchSnapshot()
}) })
it('renders without mini icons', () => { it('renders without mini icons', () => {
const supportedConnections = getConnections() const supportedConnections = getConnections()
const injectedConnection = supportedConnections[1] const injectedConnection = supportedConnections[1]
const component = render(<StatusIcon connection={injectedConnection} showMiniIcons={false} />) const component = render(<StatusIcon account={ACCOUNT} connection={injectedConnection} showMiniIcons={false} />)
expect(component.getByTestId('StatusIconRoot').children.length).toEqual(1) expect(component.getByTestId('StatusIconRoot').children.length).toEqual(0)
}) })
}) })
}) })

@ -1,4 +1,3 @@
import { useWeb3React } from '@web3-react/core'
import { Unicon } from 'components/Unicon' import { Unicon } from 'components/Unicon'
import { Connection, ConnectionType } from 'connection/types' import { Connection, ConnectionType } from 'connection/types'
import useENSAvatar from 'hooks/useENSAvatar' 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 MainWalletIcon = ({ account, connection, size }: { account: string; connection: Connection; size: number }) => {
const { account } = useWeb3React()
const { avatar } = useENSAvatar(account ?? undefined) const { avatar } = useENSAvatar(account ?? undefined)
if (!account) { if (!account) {
return null return null
} else if (avatar || (connection.type === ConnectionType.INJECTED && connection.getName() === 'MetaMask')) { } else if (avatar || (connection.type === ConnectionType.INJECTED && connection.getName() === 'MetaMask')) {
return <Identicon size={size} /> return <Identicon account={account} size={size} />
} else { } else {
return <Unicon address={account} size={size} /> return <Unicon address={account} size={size} />
} }
} }
export default function StatusIcon({ export default function StatusIcon({
account,
connection, connection,
size = 16, size = 16,
showMiniIcons = true, showMiniIcons = true,
}: { }: {
account: string
connection: Connection connection: Connection
size?: number size?: number
showMiniIcons?: boolean showMiniIcons?: boolean
@ -93,7 +93,7 @@ export default function StatusIcon({
return ( return (
<IconWrapper size={size} data-testid="StatusIconRoot"> <IconWrapper size={size} data-testid="StatusIconRoot">
<MainWalletIcon connection={connection} size={size} /> <MainWalletIcon account={account} connection={connection} size={size} />
{showMiniIcons && <MiniWalletIcon connection={connection} side="right" />} {showMiniIcons && <MiniWalletIcon connection={connection} side="right" />}
{hasSocks && showMiniIcons && <Socks />} {hasSocks && showMiniIcons && <Socks />}
</IconWrapper> </IconWrapper>

@ -108,148 +108,6 @@ exports[`StatusIcon with account renders children in correct order 1`] = `
data-testid="StatusIconRoot" data-testid="StatusIconRoot"
size="16" size="16"
> >
<div
style="height: 16px; width: 16px; position: relative;"
>
<div
style="height: 16px; width: 16px; overflow: visible; position: absolute;"
>
<svg
viewBox="0 0 16 16"
>
<defs>
<defs>
<mask
id="container-mask0x52270d8234b864dcAC9947f510CE9275A8a116Db16"
>
<rect
fill="white"
height="100%"
width="100%"
x="0"
y="0"
/>
<g
transform="scale(0.4444444444444444)
translate(0, 0)"
>
<path
d="M18.1309 3.25957C9.91898 3.40293 3.14567 10.1762 3.00231 18.3882C2.85896 26.6001 9.39985 33.141 17.6118 32.9977C25.8238 32.8543 32.5971 26.081 32.7404 17.8691L33 3L18.1309 3.25957Z"
fill="black"
/>
</g>
</mask>
<mask
id="shape-mask0x52270d8234b864dcAC9947f510CE9275A8a116Db16"
>
<rect
fill="white"
height="100%"
width="100%"
x="0"
y="0"
/>
<g
transform="scale(0.4444444444444444)
translate(10, 10)"
>
<path
clip-rule="evenodd"
d="M13.6569 13.6568C12.0059 10.0663 12.0059 5.93368 13.6569 2.34314C10.0663 3.99414 5.93368 3.99414 2.34315 2.34314C3.99414 5.93368 3.99414 10.0663 2.34315 13.6568C5.93368 12.0059 10.0663 12.0059 13.6569 13.6568ZM8 11C9.65685 11 11 9.65686 11 8.00001C11 6.34315 9.65685 5.00001 8 5.00001C6.34315 5.00001 5 6.34315 5 8.00001C5 9.65686 6.34315 11 8 11Z"
fill="black"
fill-rule="evenodd"
/>
</g>
</mask>
<mask
id="mask0x52270d8234b864dcAC9947f510CE9275A8a116Db16"
>
<g
fill="white"
>
<g
mask="url(#shape-mask0x52270d8234b864dcAC9947f510CE9275A8a116Db16)"
>
<g
transform="scale(0.4444444444444444)"
>
<path
d="M18.1309 3.25957C9.91898 3.40293 3.14567 10.1762 3.00231 18.3882C2.85896 26.6001 9.39985 33.141 17.6118 32.9977C25.8238 32.8543 32.5971 26.081 32.7404 17.8691L33 3L18.1309 3.25957Z"
/>
</g>
</g>
<g
mask="url(#container-mask0x52270d8234b864dcAC9947f510CE9275A8a116Db16)"
>
<g
transform="scale(0.4444444444444444)
translate(10, 10)"
>
<path
clip-rule="evenodd"
d="M13.6569 13.6568C12.0059 10.0663 12.0059 5.93368 13.6569 2.34314C10.0663 3.99414 5.93368 3.99414 2.34315 2.34314C3.99414 5.93368 3.99414 10.0663 2.34315 13.6568C5.93368 12.0059 10.0663 12.0059 13.6569 13.6568ZM8 11C9.65685 11 11 9.65686 11 8.00001C11 6.34315 9.65685 5.00001 8 5.00001C6.34315 5.00001 5 6.34315 5 8.00001C5 9.65686 6.34315 11 8 11Z"
fill-rule="evenodd"
/>
</g>
</g>
</g>
</mask>
</defs>
<lineargradient
id="gradient0x52270d8234b864dcAC9947f510CE9275A8a116Db16"
>
<stop
offset="0%"
stop-color="#36DBFF"
/>
<stop
offset="100%"
stop-color="#B8C3B7"
/>
</lineargradient>
<filter
height="200%"
id="blur0x52270d8234b864dcAC9947f510CE9275A8a116Db16"
width="200%"
x="-50%"
y="-50%"
>
<fegaussianblur
in="SourceGraphic"
stdDeviation="5.333333333333333"
/>
</filter>
</defs>
<g
mask="url(#mask0x52270d8234b864dcAC9947f510CE9275A8a116Db16)"
>
<rect
fill="url(#gradient0x52270d8234b864dcAC9947f510CE9275A8a116Db16)"
height="100%"
width="100%"
x="0"
y="0"
/>
<rect
fill="black"
height="100%"
opacity="0.08"
width="100%"
x="0"
y="0"
/>
<ellipse
cx="8"
cy="0"
fill="#9D99F5"
filter="url(#blur0x52270d8234b864dcAC9947f510CE9275A8a116Db16)"
rx="8"
ry="8"
/>
</g>
</svg>
</div>
</div>
<div <div
class="c1" class="c1"
> >

@ -1,5 +1,4 @@
import jazzicon from '@metamask/jazzicon' import jazzicon from '@metamask/jazzicon'
import { useWeb3React } from '@web3-react/core'
import useENSAvatar from 'hooks/useENSAvatar' import useENSAvatar from 'hooks/useENSAvatar'
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react' import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'
import styled from 'styled-components/macro' import styled from 'styled-components/macro'
@ -18,8 +17,7 @@ const StyledAvatar = styled.img`
border-radius: inherit; border-radius: inherit;
` `
export default function Identicon({ size }: { size?: number }) { export default function Identicon({ account, size }: { account: string; size?: number }) {
const { account } = useWeb3React()
const { avatar } = useENSAvatar(account ?? undefined) const { avatar } = useENSAvatar(account ?? undefined)
const [fetchable, setFetchable] = useState(true) const [fetchable, setFetchable] = useState(true)
const iconSize = size ?? 24 const iconSize = size ?? 24

@ -80,7 +80,6 @@ export default function ChainSelectorRow({ disabled, targetChain, onSelectChain,
onClick={() => { onClick={() => {
if (!disabled) onSelectChain(targetChain) if (!disabled) onSelectChain(targetChain)
}} }}
data-testid={`chain-selector-option-${label.toLowerCase()}`}
> >
<Logo src={logoUrl} alt={label} /> <Logo src={logoUrl} alt={label} />
<Label>{label}</Label> <Label>{label}</Label>

@ -7,11 +7,13 @@ import PrefetchBalancesWrapper from 'components/AccountDrawer/PrefetchBalancesWr
import Loader from 'components/Icons/LoadingSpinner' import Loader from 'components/Icons/LoadingSpinner'
import { IconWrapper } from 'components/Identicon/StatusIcon' import { IconWrapper } from 'components/Identicon/StatusIcon'
import { getConnection } from 'connection' import { getConnection } from 'connection'
import useLast from 'hooks/useLast'
import { navSearchInputVisibleSize } from 'hooks/useScreenSize' import { navSearchInputVisibleSize } from 'hooks/useScreenSize'
import { Portal } from 'nft/components/common/Portal' import { Portal } from 'nft/components/common/Portal'
import { useIsNftClaimAvailable } from 'nft/hooks/useIsNftClaimAvailable' import { useIsNftClaimAvailable } from 'nft/hooks/useIsNftClaimAvailable'
import { darken } from 'polished' import { darken } from 'polished'
import { useCallback, useMemo } from 'react' import { useCallback, useMemo } from 'react'
import { useAppSelector } from 'state/hooks'
import styled from 'styled-components/macro' import styled from 'styled-components/macro'
import { colors } from 'theme/colors' import { colors } from 'theme/colors'
import { flexRowNoWrap } from 'theme/styles' import { flexRowNoWrap } from 'theme/styles'
@ -42,7 +44,7 @@ const Web3StatusGeneric = styled(ButtonSecondary)`
} }
` `
const Web3StatusConnectWrapper = styled.div<{ faded?: boolean }>` const Web3StatusConnectWrapper = styled.div`
${flexRowNoWrap}; ${flexRowNoWrap};
align-items: center; align-items: center;
background-color: ${({ theme }) => theme.accentActionSoft}; background-color: ${({ theme }) => theme.accentActionSoft};
@ -130,8 +132,11 @@ const StyledConnectButton = styled.button`
` `
function Web3StatusInner() { 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 connection = getConnection(connector)
const [, toggleAccountDrawer] = useAccountDrawer() const [, toggleAccountDrawer] = useAccountDrawer()
const handleWalletDropdownClick = useCallback(() => { const handleWalletDropdownClick = useCallback(() => {
sendAnalyticsEvent(InterfaceEventName.ACCOUNT_DROPDOWN_BUTTON_CLICKED) sendAnalyticsEvent(InterfaceEventName.ACCOUNT_DROPDOWN_BUTTON_CLICKED)
@ -150,9 +155,7 @@ function Web3StatusInner() {
const hasPendingTransactions = !!pending.length const hasPendingTransactions = !!pending.length
if (!chainId) { if (account) {
return null
} else if (account) {
return ( return (
<TraceEvent <TraceEvent
events={[BrowserEvent.onClick]} events={[BrowserEvent.onClick]}
@ -160,12 +163,15 @@ function Web3StatusInner() {
properties={{ type: 'open' }} properties={{ type: 'open' }}
> >
<Web3StatusConnected <Web3StatusConnected
disabled={Boolean(switchingChain)}
data-testid="web3-status-connected" data-testid="web3-status-connected"
onClick={handleWalletDropdownClick} onClick={handleWalletDropdownClick}
pending={hasPendingTransactions} pending={hasPendingTransactions}
isClaimAvailable={isClaimAvailable} isClaimAvailable={isClaimAvailable}
> >
{!hasPendingTransactions && <StatusIcon size={24} connection={connection} showMiniIcons={false} />} {!hasPendingTransactions && (
<StatusIcon size={24} account={account} connection={connection} showMiniIcons={false} />
)}
{hasPendingTransactions ? ( {hasPendingTransactions ? (
<RowBetween> <RowBetween>
<Text> <Text>
@ -190,7 +196,6 @@ function Web3StatusInner() {
> >
<Web3StatusConnectWrapper <Web3StatusConnectWrapper
tabIndex={0} tabIndex={0}
faded={!account}
onKeyPress={(e) => e.key === 'Enter' && handleWalletDropdownClick()} onKeyPress={(e) => e.key === 'Enter' && handleWalletDropdownClick()}
onClick={handleWalletDropdownClick} onClick={handleWalletDropdownClick}
> >

@ -5,11 +5,8 @@ import { useEffect, useState } from 'react'
* @param value changing value * @param value changing value
* @param filterFn function that determines whether a given value should be considered for the last value * @param filterFn function that determines whether a given value should be considered for the last value
*/ */
export default function useLast<T>( export default function useLast<T>(value: T, filterFn?: (value: T) => boolean): T {
value: T | undefined | null, const [last, setLast] = useState<T>(value)
filterFn?: (value: T | null | undefined) => boolean
): T | null | undefined {
const [last, setLast] = useState<T | null | undefined>(filterFn && filterFn(value) ? value : undefined)
useEffect(() => { useEffect(() => {
setLast((last) => { setLast((last) => {
const shouldUse: boolean = filterFn ? filterFn(value) : true const shouldUse: boolean = filterFn ? filterFn(value) : true

@ -5,11 +5,13 @@ import { SupportedChainId } from 'constants/chains'
import { useCallback } from 'react' import { useCallback } from 'react'
import { addPopup } from 'state/application/reducer' import { addPopup } from 'state/application/reducer'
import { useAppDispatch } from 'state/hooks' import { useAppDispatch } from 'state/hooks'
import { switchChain } from 'utils/switchChain'
import { useSwitchChain } from './useSwitchChain'
export default function useSelectChain() { export default function useSelectChain() {
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const { connector } = useWeb3React() const { connector } = useWeb3React()
const switchChain = useSwitchChain()
return useCallback( return useCallback(
async (targetChain: SupportedChainId) => { async (targetChain: SupportedChainId) => {
@ -20,15 +22,12 @@ export default function useSelectChain() {
try { try {
await switchChain(connector, targetChain) await switchChain(connector, targetChain)
} catch (error) { } catch (error) {
if (didUserReject(connection, error)) { if (!didUserReject(connection, error)) {
return 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]
) )
} }

@ -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]
)
}

@ -18,6 +18,7 @@ import { useNftUniversalRouterAddress } from 'graphql/data/nft/NftUniversalRoute
import { useCurrency } from 'hooks/Tokens' import { useCurrency } from 'hooks/Tokens'
import { AllowanceState } from 'hooks/usePermit2Allowance' import { AllowanceState } from 'hooks/usePermit2Allowance'
import { useStablecoinValue } from 'hooks/useStablecoinPrice' import { useStablecoinValue } from 'hooks/useStablecoinPrice'
import { useSwitchChain } from 'hooks/useSwitchChain'
import { useTokenBalance } from 'lib/hooks/useCurrencyBalance' import { useTokenBalance } from 'lib/hooks/useCurrencyBalance'
import tryParseCurrencyAmount from 'lib/utils/tryParseCurrencyAmount' import tryParseCurrencyAmount from 'lib/utils/tryParseCurrencyAmount'
import { useBag } from 'nft/hooks/useBag' import { useBag } from 'nft/hooks/useBag'
@ -37,7 +38,6 @@ import { AlertTriangle, ChevronDown } from 'react-feather'
import { InterfaceTrade, TradeState } from 'state/routing/types' import { InterfaceTrade, TradeState } from 'state/routing/types'
import styled, { useTheme } from 'styled-components/macro' import styled, { useTheme } from 'styled-components/macro'
import { ThemedText } from 'theme' import { ThemedText } from 'theme'
import { switchChain } from 'utils/switchChain'
import { shallow } from 'zustand/shallow' import { shallow } from 'zustand/shallow'
import { BuyButtonStateData, BuyButtonStates, getBuyButtonStateData } from './ButtonStates' import { BuyButtonStateData, BuyButtonStates, getBuyButtonStateData } from './ButtonStates'
@ -348,6 +348,7 @@ export const BagFooter = ({ setModalIsOpen, eventProperties }: BagFooterProps) =
setBagStatus(BagStatus.ADDING_TO_BAG) setBagStatus(BagStatus.ADDING_TO_BAG)
}, [inputCurrency, setBagStatus]) }, [inputCurrency, setBagStatus])
const switchChain = useSwitchChain()
const { const {
buttonText, buttonText,
buttonTextColor, buttonTextColor,
@ -441,6 +442,7 @@ export const BagFooter = ({ setModalIsOpen, eventProperties }: BagFooterProps) =
priceImpact, priceImpact,
theme, theme,
fetchAssets, fetchAssets,
switchChain,
connector, connector,
toggleWalletDrawer, toggleWalletDrawer,
setBagExpanded, setBagExpanded,

@ -26,6 +26,7 @@ import { useMaxAmountIn } from 'hooks/useMaxAmountIn'
import usePermit2Allowance, { AllowanceState } from 'hooks/usePermit2Allowance' import usePermit2Allowance, { AllowanceState } from 'hooks/usePermit2Allowance'
import usePrevious from 'hooks/usePrevious' import usePrevious from 'hooks/usePrevious'
import { useSwapCallback } from 'hooks/useSwapCallback' import { useSwapCallback } from 'hooks/useSwapCallback'
import { useSwitchChain } from 'hooks/useSwitchChain'
import { useUSDPrice } from 'hooks/useUSDPrice' import { useUSDPrice } from 'hooks/useUSDPrice'
import JSBI from 'jsbi' import JSBI from 'jsbi'
import { formatSwapQuoteReceivedEventProperties } from 'lib/utils/analytics' import { formatSwapQuoteReceivedEventProperties } from 'lib/utils/analytics'
@ -33,11 +34,11 @@ import { ReactNode, useCallback, useEffect, useMemo, useReducer, useState } from
import { ArrowDown } from 'react-feather' import { ArrowDown } from 'react-feather'
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
import { Text } from 'rebass' import { Text } from 'rebass'
import { useAppSelector } from 'state/hooks'
import { InterfaceTrade, TradeState } from 'state/routing/types' import { InterfaceTrade, TradeState } from 'state/routing/types'
import styled, { useTheme } from 'styled-components/macro' import styled, { useTheme } from 'styled-components/macro'
import { currencyAmountToPreciseFloat, formatTransactionAmount } from 'utils/formatNumbers' import { currencyAmountToPreciseFloat, formatTransactionAmount } from 'utils/formatNumbers'
import { didUserReject } from 'utils/swapErrorToUserReadableMessage' import { didUserReject } from 'utils/swapErrorToUserReadableMessage'
import { switchChain } from 'utils/switchChain'
import AddressInputPanel from '../../components/AddressInputPanel' import AddressInputPanel from '../../components/AddressInputPanel'
import { ButtonError, ButtonLight, ButtonPrimary } from '../../components/Button' import { ButtonError, ButtonLight, ButtonPrimary } from '../../components/Button'
@ -536,6 +537,9 @@ export function Swap({
!showWrap && userHasSpecifiedInputOutput && (trade || routeIsLoading || routeIsSyncing) !showWrap && userHasSpecifiedInputOutput && (trade || routeIsLoading || routeIsSyncing)
) )
const switchChain = useSwitchChain()
const switchingChain = useAppSelector((state) => state.wallets.switchingChain)
return ( return (
<SwapWrapper chainId={chainId} className={className} id="swap-page"> <SwapWrapper chainId={chainId} className={className} id="swap-page">
<TokenSafetyModal <TokenSafetyModal
@ -669,6 +673,10 @@ export function Swap({
<Trans>Unsupported Asset</Trans> <Trans>Unsupported Asset</Trans>
</ThemedText.DeprecatedMain> </ThemedText.DeprecatedMain>
</ButtonPrimary> </ButtonPrimary>
) : switchingChain ? (
<ButtonPrimary disabled={true}>
<Trans>Connecting to {getChainInfo(switchingChain)?.label}</Trans>
</ButtonPrimary>
) : !account ? ( ) : !account ? (
<TraceEvent <TraceEvent
events={[BrowserEvent.onClick]} events={[BrowserEvent.onClick]}

@ -3,24 +3,44 @@ import { Wallet } from './types'
const WALLET: Wallet = { account: '0x123', walletType: 'test' } const WALLET: Wallet = { account: '0x123', walletType: 'test' }
describe('walletsSlice reducers', () => { const INITIAL_STATE = { connectedWallets: [] as Wallet[], switchingChain: false as const }
it('should add a connected wallet', () => {
const initialState = { connectedWallets: [] } describe('wallets reducer', () => {
const action = { describe('connectedWallets', () => {
type: 'wallets/addConnectedWallet', it('should add a connected wallet', () => {
payload: WALLET, const action = {
} type: 'wallets/addConnectedWallet',
const expectedState = { connectedWallets: [WALLET] } payload: WALLET,
expect(walletsReducer(initialState, action)).toEqual(expectedState) }
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', () => { describe('switchingChain', () => {
const initialState = { connectedWallets: [WALLET] } it('should start switching to chain', () => {
const action = { const action = {
type: 'wallets/addConnectedWallet', type: 'wallets/startSwitchingChain',
payload: WALLET, payload: 1,
} }
const expectedState = { connectedWallets: [WALLET] } const expectedState = { connectedWallets: [], switchingChain: 1 }
expect(walletsReducer(initialState, action)).toEqual(expectedState) 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)
})
}) })
}) })

@ -1,16 +1,19 @@
import { createSlice } from '@reduxjs/toolkit' import { createSlice } from '@reduxjs/toolkit'
import { SupportedChainId } from 'constants/chains'
import { shallowEqual } from 'react-redux' import { shallowEqual } from 'react-redux'
import { Wallet } from './types' 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 { 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[] connectedWallets: Wallet[]
switchingChain: SupportedChainId | false
} }
const initialState: WalletState = { const initialState: WalletState = {
connectedWallets: [], connectedWallets: [],
switchingChain: false,
} }
const walletsSlice = createSlice({ const walletsSlice = createSlice({
@ -21,8 +24,14 @@ const walletsSlice = createSlice({
if (state.connectedWallets.some((wallet) => shallowEqual(payload, wallet))) return if (state.connectedWallets.some((wallet) => shallowEqual(payload, wallet))) return
state.connectedWallets = [...state.connectedWallets, payload] 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 export default walletsSlice.reducer

@ -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)
}
}

@ -9109,10 +9109,10 @@ csstype@^3.0.2, csstype@^3.0.7:
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.0.tgz#4ddcac3718d787cf9df0d1b7d15033925c8f29f2" resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.0.tgz#4ddcac3718d787cf9df0d1b7d15033925c8f29f2"
integrity sha512-uX1KG+x9h5hIJsaKR9xHUeUraxf8IODOwq9JLNPq6BwB04a/xgpq3rcx47l5BZu5zBPlgD342tdke3Hom/nJRA== integrity sha512-uX1KG+x9h5hIJsaKR9xHUeUraxf8IODOwq9JLNPq6BwB04a/xgpq3rcx47l5BZu5zBPlgD342tdke3Hom/nJRA==
cypress-hardhat@^2.3.0: cypress-hardhat@^2.4.1:
version "2.3.0" version "2.4.1"
resolved "https://registry.yarnpkg.com/cypress-hardhat/-/cypress-hardhat-2.3.0.tgz#646b35d57490d060e3fd4441e76e4d91b4ff4ec7" resolved "https://registry.yarnpkg.com/cypress-hardhat/-/cypress-hardhat-2.4.1.tgz#dff41b06a85c4a572d43ae662f2a0cd03c29c4d7"
integrity sha512-Sj437lFrUZ9UJGXS5a+DLQPBoyaWUxJafEiydNqKKpViKswBiylHD3ZJu2mrtQ/fhp7lgOPpMP72IQX4Ncwzdg== integrity sha512-D9keayw+9C1YGPTXEfkXDGmPusMoA5Sg2fiRoaBgKHO53UUFQRKnwa6HCTkCxcd0t+Hh8UcwpFwkxtlXh5QtjA==
dependencies: dependencies:
"@uniswap/permit2-sdk" "^1.2.0" "@uniswap/permit2-sdk" "^1.2.0"
"@uniswap/sdk-core" "^3.0.1" "@uniswap/sdk-core" "^3.0.1"