Compare commits

...

9 Commits

Author SHA1 Message Date
eddie
1b2d86ae3a feat: remove amplitude swap error logging (#6306) 2023-04-05 16:09:07 -07:00
Zach Pomerantz
40cac44e07 docs: use comments for pull_request_template (#6304)
* docs: use comments for pull_request_template

* docs: update
2023-04-05 09:12:38 -07:00
eddie
4e6d28cff4 feat: Update pull_request_template.md (#6302)
Update pull_request_template.md

add notes about testing mobile layouts to the test plan prompts
2023-04-04 14:57:47 -07:00
eddie
709fad0804 test: add unit test coverage to some redux state files (#6285) 2023-04-04 09:46:24 -07:00
Zach Pomerantz
573f4c873a fix: omit failed eth_blockNumber calls from sentry (#6267)
* build: upgrade sentry

* fix: omit failed eth_blockNumber calls from sentry

* test: beforeSend

* fix: bring to parity with #6281

* docs: type filterKnownErrors to beforeSend
2023-04-03 15:12:33 -07:00
eddie
d300db669f fix: z index issue with socks icon (#6295) 2023-04-03 15:12:22 -07:00
eddie
fb8217ddea fix: dont block trade when price impact is favorable (#6261)
* fix: dont block trade when price impact is favorable

* fix: add comment
2023-04-03 14:13:27 -07:00
Jordan Frankfurt
7b9a23d920 feat: reduce severity of phishing filter to allow url token names (#6282)
* feat: reduce severity of phishing filter to allow url token names

* tests

* remove unused var from test

* test rendering mini portfolio pools list

* update owner

* update variable names to match cmcewen's suggestions

* checkStringForURL -> hasURL
2023-03-31 12:59:02 -05:00
Connor McEwen
120ad935fa revert: "fix: mini portfolio layout fixes" (#6279)
Revert "fix: mini portfolio layout fixes (#6260)"

This reverts commit fb05439d32.
2023-03-30 18:47:42 -04:00
21 changed files with 706 additions and 83 deletions

View File

@@ -1,22 +1,45 @@
<!-- Your PR title must follow conventional commits: https://github.com/Uniswap/interface#pr-title -->
## Description
_[Summary of change, motivation, and context.]_
- _Link to JIRA ticket, slack thread, or relevant docs helpful for providing context to reviewers._
- _Note: Your PR title must follow conventions [outlined here](https://github.com/Uniswap/interface#contributions)._
## Screen Capture
| Before | After |
| ---------------- |-----------------|
| _insert_before_ | _insert_after_ |
<!-- Summary of change, including motivation and context. -->
<!-- Use verb-driven language: "Fixes XYZ" instead of "This change fixes XYZ" -->
## Test Plan
#### Manual
<!-- Delete inapplicable lines: -->
_JIRA ticket:_
_Slack thread:_
_Relevant docs:_
_[Steps of how you are testing the change and ensuring no regression.]_
#### Automated
<!-- Delete this section if your change does not affect UI. -->
## Screen capture
| Before | After (Desktop) | After (Mobile) |
| ----------------------- |----------------------- | ---------------------- |
| <!-- Paste "before" --> | <!-- Paste "after" --> | <!-- Paste "after" --> |
## Test plan
<!-- Delete this section if your change is not a bug fix. -->
### Reproducing the error
<!-- Include steps to reproduce the bug. -->
1.
### QA (ie manual testing)
<!-- Include steps to test the change, ensuring no regression. -->
- [ ] N/A
#### Devices
<!-- If applicable, include different devices and screen sizes that may be affected, and how you've tested them. -->
### Automated testing
<!-- If N/A, do not check nor delete, but strike through. -->
<!-- eg - [ ] <s>Unit test</s> -->
- [ ] Unit test
- [ ] Integration/E2E test

View File

@@ -37,8 +37,12 @@
"src/lib/utils/**/*.ts*",
"src/pages/**/*.ts*",
"src/state/**/*.ts*",
"src/tracing/**/*.ts*",
"src/utils/**/*.ts*"
],
"coveragePathIgnorePatterns": [
".snap"
],
"coverageThreshold": {
"global": {
"branches": 4,

View File

@@ -1,13 +1,10 @@
import { Trans } from '@lingui/macro'
import * as Sentry from '@sentry/react'
import { sendAnalyticsEvent } from '@uniswap/analytics'
import { SwapEventName } from '@uniswap/analytics-events'
import { ButtonLight, SmallButtonPrimary } from 'components/Button'
import { ChevronUpIcon } from 'nft/components/icons'
import { useIsMobile } from 'nft/hooks'
import React, { PropsWithChildren, useState } from 'react'
import { Copy } from 'react-feather'
import { useLocation } from 'react-router-dom'
import styled from 'styled-components/macro'
import { isSentryEnabled } from 'utils/env'
@@ -220,18 +217,14 @@ const updateServiceWorkerInBackground = async () => {
}
export default function ErrorBoundary({ children }: PropsWithChildren): JSX.Element {
const { pathname } = useLocation()
return (
<Sentry.ErrorBoundary
fallback={({ error, eventId }) => <Fallback error={error} eventId={eventId} />}
beforeCapture={(scope) => {
scope.setLevel('fatal')
}}
onError={(error) => {
onError={() => {
updateServiceWorkerInBackground()
if (pathname === '/swap') {
sendAnalyticsEvent(SwapEventName.SWAP_ERROR, { error })
}
}}
>
{children}

View File

@@ -0,0 +1,24 @@
import { getConnections } from 'connection'
import { render } from 'test-utils'
import StatusIcon from './StatusIcon'
jest.mock('../../hooks/useSocksBalance', () => ({
useHasSocks: () => true,
}))
describe('StatusIcon', () => {
it('renders children in correct order, with no account and with socks', () => {
const supportedConnections = getConnections()
const injectedConnection = supportedConnections[1]
const component = render(<StatusIcon connection={injectedConnection} />)
expect(component.getByTestId('StatusIconRoot')).toMatchSnapshot()
})
it('renders with no account and showMiniIcons=false', () => {
const supportedConnections = getConnections()
const injectedConnection = supportedConnections[1]
const component = render(<StatusIcon connection={injectedConnection} showMiniIcons={false} />)
expect(component.getByTestId('StatusIconRoot').children.length).toEqual(0)
})
})

View File

@@ -90,10 +90,10 @@ export default function StatusIcon({
const hasSocks = useHasSocks()
return (
<IconWrapper size={size}>
{hasSocks && showMiniIcons && <Socks />}
<IconWrapper size={size} data-testid="StatusIconRoot">
<MainWalletIcon connection={connection} size={size} />
{showMiniIcons && <MiniWalletIcon connection={connection} side="right" />}
{hasSocks && showMiniIcons && <Socks />}
</IconWrapper>
)
}

View File

@@ -0,0 +1,129 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`StatusIcon renders children in correct order, with no account and with socks 1`] = `
.c0 {
position: relative;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-flex-flow: column nowrap;
-ms-flex-flow: column nowrap;
flex-flow: column nowrap;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-pack: center;
-webkit-justify-content: center;
-ms-flex-pack: center;
justify-content: center;
margin-right: 8px;
}
.c0 > img,
.c0 span {
height: 16px;
width: 16px;
}
.c1 {
position: absolute;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: center;
-webkit-justify-content: center;
-ms-flex-pack: center;
justify-content: center;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
width: 16px;
height: 16px;
bottom: -4px;
right: -4px;
border-radius: 50%;
outline: 2px solid #FFFFFF;
outline-offset: -0.1px;
background-color: #FFFFFF;
overflow: hidden;
}
.c3 {
position: absolute;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: center;
-webkit-justify-content: center;
-ms-flex-pack: center;
justify-content: center;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
width: 16px;
height: 16px;
bottom: -4px;
left: -4px;
border-radius: 50%;
outline: 2px solid #FFFFFF;
outline-offset: -0.1px;
background-color: #FFFFFF;
overflow: hidden;
}
.c2 {
width: 16px;
height: 16px;
}
@media (max-width:960px) {
.c0 {
-webkit-align-items: flex-end;
-webkit-box-align: flex-end;
-ms-flex-align: flex-end;
align-items: flex-end;
}
}
@supports (overflow:clip) {
.c1 {
overflow: clip;
}
}
@supports (overflow:clip) {
.c3 {
overflow: clip;
}
}
<div
class="c0"
data-testid="StatusIconRoot"
size="16"
>
<div
class="c1"
>
<img
alt="MetaMask icon"
class="c2"
src="metamask.svg"
/>
</div>
<div
class="c3"
>
<img
class="c2"
src="socks.svg"
/>
</div>
</div>
`;

View File

@@ -1,38 +1,91 @@
import { BigNumber } from '@ethersproject/bignumber'
import { render, screen } from 'test-utils'
import { SupportedChainId, Token, WETH9 } from '@uniswap/sdk-core'
import { FeeAmount, Pool } from '@uniswap/v3-sdk'
import { USDC_MAINNET } from 'constants/tokens'
import { useToken } from 'hooks/Tokens'
import { usePool } from 'hooks/usePools'
import { PoolState } from 'hooks/usePools'
import { render } from 'test-utils'
import { unwrappedToken } from 'utils/unwrappedToken'
import PositionListItem from '.'
jest.mock('hooks/Tokens', () => {
const originalModule = jest.requireActual('hooks/Tokens')
const uniSDK = jest.requireActual('@uniswap/sdk-core')
jest.mock('utils/unwrappedToken')
const mockUnwrappedToken = unwrappedToken as jest.MockedFunction<typeof unwrappedToken>
jest.mock('hooks/usePools')
const mockUsePool = usePool as jest.MockedFunction<typeof usePool>
jest.mock('hooks/Tokens')
const mockUseToken = useToken as jest.MockedFunction<typeof useToken>
// eslint-disable-next-line react/display-name
jest.mock('components/DoubleLogo', () => () => <div />)
jest.mock('@web3-react/core', () => {
const web3React = jest.requireActual('@web3-react/core')
return {
__esModule: true,
...originalModule,
useToken: jest.fn(
() =>
new uniSDK.Token(
1,
'0x39AA39c021dfbaE8faC545936693aC917d5E7563',
8,
'https://www.example.com',
'example.com coin'
)
),
...web3React,
useWeb3React: () => ({
chainId: 1,
}),
}
})
test('PositionListItem should not render when the name contains a url', () => {
const susToken0Address = '0x39AA39c021dfbaE8faC545936693aC917d5E7563'
beforeEach(() => {
const susToken0 = new Token(1, susToken0Address, 8, 'https://www.example.com', 'example.com coin')
mockUseToken.mockImplementation((tokenAddress?: string | null | undefined) => {
if (!tokenAddress) return null
if (tokenAddress === susToken0.address) return susToken0
return new Token(1, tokenAddress, 8, 'symbol', 'name')
})
mockUsePool.mockReturnValue([
PoolState.EXISTS,
new Pool(susToken0, USDC_MAINNET, FeeAmount.HIGH, '2437312313659959819381354528', '10272714736694327408', -69633),
])
mockUnwrappedToken.mockReturnValue(susToken0)
})
test('PositionListItem should not render when token0 symbol contains a url', () => {
const positionDetails = {
token0: '0x39AA39c021dfbaE8faC545936693aC917d5E7563',
token1: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2',
token0: susToken0Address,
token1: USDC_MAINNET.address,
tokenId: BigNumber.from(436148),
fee: 100,
liquidity: BigNumber.from('0x5c985aff8059be04'),
tickLower: -800,
tickUpper: 1600,
}
render(<PositionListItem {...positionDetails} />)
screen.debug()
expect(screen.queryByText('.com', { exact: false })).toBe(null)
const { container } = render(<PositionListItem {...positionDetails} />)
expect(container).toBeEmptyDOMElement()
})
test('PositionListItem should not render when token1 symbol contains a url', () => {
const positionDetails = {
token0: USDC_MAINNET.address,
token1: susToken0Address,
tokenId: BigNumber.from(436148),
fee: 100,
liquidity: BigNumber.from('0x5c985aff8059be04'),
tickLower: -800,
tickUpper: 1600,
}
const { container } = render(<PositionListItem {...positionDetails} />)
expect(container).toBeEmptyDOMElement()
})
test('PositionListItem should render a position', () => {
const positionDetails = {
token0: USDC_MAINNET.address,
token1: WETH9[SupportedChainId.MAINNET].address,
tokenId: BigNumber.from(436148),
fee: 100,
liquidity: BigNumber.from('0x5c985aff8059be04'),
tickLower: -800,
tickUpper: 1600,
}
const { container } = render(<PositionListItem {...positionDetails} />)
expect(container).not.toBeEmptyDOMElement()
})

View File

@@ -203,12 +203,9 @@ export default function PositionListItem({
const removed = liquidity?.eq(0)
const containsURL = useMemo(
() => [token0?.name, token0?.symbol, token1?.name, token1?.symbol].some((testString) => hasURL(testString)),
[token0?.name, token0?.symbol, token1?.name, token1?.symbol]
)
const shouldHidePosition = hasURL(token0?.symbol) || hasURL(token1?.symbol)
if (containsURL) {
if (shouldHidePosition) {
return null
}

View File

@@ -36,7 +36,7 @@ import MiniPortfolio from './MiniPortfolio'
import { portfolioFadeInAnimation } from './MiniPortfolio/PortfolioRow'
const AuthenticatedHeaderWrapper = styled.div`
padding: 20px 16px;
padding: 14px 12px 16px 16px;
display: flex;
flex-direction: column;
flex: 1;

View File

@@ -0,0 +1,67 @@
import { BigNumber } from '@ethersproject/bignumber'
import { SupportedChainId, WETH9 } from '@uniswap/sdk-core'
import { FeeAmount, Pool, Position } from '@uniswap/v3-sdk'
import { USDC_MAINNET } from 'constants/tokens'
import { render } from 'test-utils'
import Pools from '.'
import useMultiChainPositions from './useMultiChainPositions'
jest.mock('./useMultiChainPositions')
const mockUseMultiChainPositions = useMultiChainPositions as jest.MockedFunction<typeof useMultiChainPositions>
const owner = '0xf5b6bb25f5beaea03dd014c6ef9fa9f3926bf36c'
const pool = new Pool(
USDC_MAINNET,
WETH9[SupportedChainId.MAINNET],
FeeAmount.MEDIUM,
'1851127709498178402383049949138810',
'7076437181775065414',
201189
)
const position = new Position({
pool,
liquidity: 1341008833950736,
tickLower: 200040,
tickUpper: 202560,
})
const details = {
nonce: BigNumber.from('0'),
tokenId: BigNumber.from('0'),
operator: '0x0',
token0: USDC_MAINNET.address,
token1: WETH9[SupportedChainId.MAINNET].address,
fee: FeeAmount.MEDIUM,
tickLower: -100,
tickUpper: 100,
liquidity: BigNumber.from('9000'),
feeGrowthInside0LastX128: BigNumber.from('0'),
feeGrowthInside1LastX128: BigNumber.from('0'),
tokensOwed0: BigNumber.from('0'),
tokensOwed1: BigNumber.from('0'),
}
const useMultiChainPositionsReturnValue = {
positions: [
{
owner,
chainId: SupportedChainId.MAINNET,
position,
pool,
details,
inRange: true,
closed: false,
},
],
loading: false,
}
beforeEach(() => {
mockUseMultiChainPositions.mockReturnValue(useMultiChainPositionsReturnValue)
})
test('Pools should render LP positions', () => {
const props = { account: owner }
const { container } = render(<Pools {...props} />)
expect(container).not.toBeEmptyDOMElement()
})

View File

@@ -111,15 +111,9 @@ function PositionListItem({ positionInfo }: { positionInfo: PositionInfo }) {
[chainId, pool.token0.address, pool.token0.symbol, pool.token1.address, pool.token1.symbol]
)
const containsURL = useMemo(
() =>
[pool.token0.name, pool.token0.symbol, pool.token1.name, pool.token1.symbol].some((testString) =>
hasURL(testString)
),
[pool]
)
const shouldHidePosition = hasURL(pool.token0.symbol) || hasURL(pool.token1.symbol)
if (containsURL) {
if (shouldHidePosition) {
return null
}

View File

@@ -76,22 +76,16 @@ const WalletDropdownScrollWrapper = styled.div`
border-radius: 12px;
`
const Wrapper = styled.div`
display: flex;
flex-direction: row;
height: calc(100% - 2 * ${DRAWER_MARGIN});
overflow: hidden;
position: fixed;
right: ${DRAWER_MARGIN};
top: ${DRAWER_MARGIN};
z-index: ${Z_INDEX.fixed};
`
const WalletDropdownWrapper = styled.div<{ open: boolean }>`
margin-right: ${({ open }) => (open ? 0 : '-' + DRAWER_WIDTH)};
height: 100%;
position: fixed;
top: ${DRAWER_MARGIN};
right: ${({ open }) => (open ? DRAWER_MARGIN : '-' + DRAWER_WIDTH)};
z-index: ${Z_INDEX.fixed};
overflow: hidden;
height: calc(100% - 2 * ${DRAWER_MARGIN});
@media only screen and (max-width: ${({ theme }) => `${theme.breakpoint.sm}px`}) {
z-index: ${Z_INDEX.modal};
top: unset;
@@ -107,7 +101,7 @@ const WalletDropdownWrapper = styled.div<{ open: boolean }>`
}
@media screen and (min-width: 1440px) {
margin-right: ${({ open }) => (open ? 0 : '-' + DRAWER_WIDTH_XL)};
right: ${({ open }) => (open ? DRAWER_MARGIN : '-' + DRAWER_WIDTH_XL)};
width: ${DRAWER_WIDTH_XL};
}
@@ -118,7 +112,7 @@ const WalletDropdownWrapper = styled.div<{ open: boolean }>`
border: ${({ theme }) => `1px solid ${theme.backgroundOutline}`};
box-shadow: ${({ theme }) => theme.deepShadow};
transition: margin-right ${({ theme }) => theme.transition.duration.medium},
transition: right ${({ theme }) => theme.transition.duration.medium},
bottom ${({ theme }) => theme.transition.duration.medium};
`
@@ -129,7 +123,11 @@ const CloseIcon = styled(ChevronsRight).attrs({ size: 24 })`
const CloseDrawer = styled.div`
${ClickableStyle}
cursor: pointer;
height: 100%;
height: calc(100% - 2 * ${DRAWER_MARGIN});
position: fixed;
right: calc(${DRAWER_MARGIN} + ${DRAWER_WIDTH} - ${DRAWER_OFFSET});
top: 4px;
z-index: ${Z_INDEX.dropdown};
// When the drawer is not hovered, the icon should be 18px from the edge of the sidebar.
padding: 24px calc(18px + ${DRAWER_OFFSET}) 24px 14px;
border-radius: 20px 0 0 20px;
@@ -142,6 +140,9 @@ const CloseDrawer = styled.div`
@media only screen and (max-width: ${({ theme }) => `${theme.breakpoint.sm}px`}) {
display: none;
}
@media screen and (min-width: 1440px) {
right: calc(${DRAWER_MARGIN} + ${DRAWER_WIDTH_XL} - ${DRAWER_OFFSET});
}
`
function WalletDropdown() {
@@ -186,7 +187,7 @@ function WalletDropdown() {
}, [walletDrawerOpen, toggleWalletDrawer])
return (
<Wrapper>
<>
{walletDrawerOpen && (
<TraceEvent
events={[BrowserEvent.onClick]}
@@ -205,7 +206,7 @@ function WalletDropdown() {
<DefaultMenu />
</WalletDropdownScrollWrapper>
</WalletDropdownWrapper>
</Wrapper>
</>
)
}

View File

@@ -0,0 +1,21 @@
import { USDC_MAINNET } from 'constants/tokens'
import { deserializeToken, serializeToken } from './hooks'
describe('serializeToken', () => {
it('serializes the token', () => {
expect(serializeToken(USDC_MAINNET)).toEqual({
chainId: 1,
decimals: 6,
name: 'USD//C',
symbol: 'USDC',
address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
})
})
})
describe('deserializeToken', () => {
it('deserializes the token', () => {
expect(deserializeToken(serializeToken(USDC_MAINNET))).toEqual(USDC_MAINNET)
})
})

View File

@@ -2,7 +2,33 @@ import { createStore, Store } from 'redux'
import { DEFAULT_DEADLINE_FROM_NOW } from '../../constants/misc'
import { updateVersion } from '../global/actions'
import reducer, { initialState, UserState } from './reducer'
import reducer, {
addSerializedPair,
addSerializedToken,
initialState,
updateHideClosedPositions,
updateHideUniswapWalletBanner,
updateSelectedWallet,
updateUserClientSideRouter,
updateUserDeadline,
updateUserExpertMode,
updateUserLocale,
updateUserSlippageTolerance,
UserState,
} from './reducer'
function buildSerializedPair(token0Address: string, token1Address: string, chainId: number) {
return {
token0: {
chainId,
address: token0Address,
},
token1: {
chainId,
address: token1Address,
},
}
}
describe('swap reducer', () => {
let store: Store<UserState>
@@ -30,5 +56,176 @@ describe('swap reducer', () => {
expect(store.getState().userDeadline).toEqual(DEFAULT_DEADLINE_FROM_NOW)
expect(store.getState().userSlippageTolerance).toEqual('auto')
})
it('sets allowed slippage and deadline to auto', () => {
store = createStore(reducer, {
...initialState,
userSlippageTolerance: 10,
userSlippageToleranceHasBeenMigratedToAuto: undefined,
} as any)
store.dispatch(updateVersion())
expect(store.getState().userSlippageToleranceHasBeenMigratedToAuto).toEqual(true)
})
})
describe('updateSelectedWallet', () => {
it('updates the selected wallet', () => {
store.dispatch(updateSelectedWallet({ wallet: 'metamask' }))
expect(store.getState().selectedWallet).toEqual('metamask')
})
})
describe('updateUserExpertMode', () => {
it('updates the userExpertMode', () => {
store.dispatch(updateUserExpertMode({ userExpertMode: true }))
expect(store.getState().userExpertMode).toEqual(true)
})
})
describe('updateUserLocale', () => {
it('updates the userLocale', () => {
store.dispatch(updateUserLocale({ userLocale: 'en' }))
expect(store.getState().userLocale).toEqual('en')
})
})
describe('updateUserSlippageTolerance', () => {
it('updates the userSlippageTolerance', () => {
store.dispatch(updateUserSlippageTolerance({ userSlippageTolerance: '0.5' }))
expect(store.getState().userSlippageTolerance).toEqual('0.5')
})
})
describe('updateUserDeadline', () => {
it('updates the userDeadline', () => {
store.dispatch(updateUserDeadline({ userDeadline: 5 }))
expect(store.getState().userDeadline).toEqual(5)
})
})
describe('updateUserClientSideRouter', () => {
it('updates the userClientSideRouter', () => {
store.dispatch(updateUserClientSideRouter({ userClientSideRouter: true }))
expect(store.getState().userClientSideRouter).toEqual(true)
})
})
describe('updateHideClosedPositions', () => {
it('updates the userHideClosedPositions', () => {
store.dispatch(updateHideClosedPositions({ userHideClosedPositions: true }))
expect(store.getState().userHideClosedPositions).toEqual(true)
})
})
describe('updateHideUniswapWalletBanner', () => {
it('updates the hideUniswapWalletBanner', () => {
store.dispatch(updateHideUniswapWalletBanner({ hideUniswapWalletBanner: true }))
expect(store.getState().hideUniswapWalletBanner).toEqual(true)
})
})
describe('addSerializedToken', () => {
it('adds a token to the uninitialized list', () => {
store = createStore(reducer, {
...initialState,
tokens: undefined as any,
})
store.dispatch(
addSerializedToken({
serializedToken: {
chainId: 1,
address: '0x123',
},
})
)
expect(store.getState().tokens).toEqual({ 1: { '0x123': { address: '0x123', chainId: 1 } } })
})
it('adds a token to the initialized list, no duplicates', () => {
store.dispatch(addSerializedToken({ serializedToken: { chainId: 1, address: '0x123' } }))
store.dispatch(addSerializedToken({ serializedToken: { chainId: 1, address: '0x123' } }))
expect(store.getState().tokens).toEqual({ 1: { '0x123': { address: '0x123', chainId: 1 } } })
})
it('adds a new token to the initialized list', () => {
store.dispatch(addSerializedToken({ serializedToken: { chainId: 1, address: '0x123' } }))
store.dispatch(addSerializedToken({ serializedToken: { chainId: 1, address: '0x456' } }))
expect(store.getState().tokens).toEqual({
1: {
'0x123': { address: '0x123', chainId: 1 },
'0x456': { address: '0x456', chainId: 1 },
},
})
})
})
describe('addSerializedPair', () => {
it('adds a pair to the uninitialized list', () => {
store = createStore(reducer, {
...initialState,
})
store.dispatch(
addSerializedPair({
serializedPair: buildSerializedPair('0x123', '0x456', 1),
})
)
expect(store.getState().pairs).toEqual({
1: { '0x123;0x456': buildSerializedPair('0x123', '0x456', 1) },
})
})
it('adds two pair to the initialized list, no duplicates', () => {
store.dispatch(
addSerializedPair({
serializedPair: buildSerializedPair('0x123', '0x456', 1),
})
)
store.dispatch(
addSerializedPair({
serializedPair: buildSerializedPair('0x123', '0x456', 1),
})
)
expect(store.getState().pairs).toEqual({
1: { '0x123;0x456': buildSerializedPair('0x123', '0x456', 1) },
})
})
it('adds two new pairs to the initialized list, same chain', () => {
store.dispatch(
addSerializedPair({
serializedPair: buildSerializedPair('0x123', '0x456', 1),
})
)
store.dispatch(
addSerializedPair({
serializedPair: buildSerializedPair('0x123', '0x789', 1),
})
)
expect(store.getState().pairs).toEqual({
1: {
'0x123;0x456': buildSerializedPair('0x123', '0x456', 1),
'0x123;0x789': buildSerializedPair('0x123', '0x789', 1),
},
})
})
it('adds two new pairs to the initialized list, different chains', () => {
store.dispatch(
addSerializedPair({
serializedPair: buildSerializedPair('0x123', '0x456', 1),
})
)
store.dispatch(
addSerializedPair({
serializedPair: buildSerializedPair('0x123', '0x456', 5),
})
)
expect(store.getState().pairs).toEqual({
1: {
'0x123;0x456': buildSerializedPair('0x123', '0x456', 1),
},
5: {
'0x123;0x456': buildSerializedPair('0x123', '0x456', 5),
},
})
})
})
})

View File

@@ -0,0 +1,22 @@
import { act, renderHook } from 'test-utils'
import { useConnectedWallets } from './hooks'
import { Wallet } from './types'
describe('useConnectedWallets', () => {
it('should return the connected wallets', () => {
const { result } = renderHook(() => useConnectedWallets())
expect(result.current[0]).toEqual([])
})
it('should add a wallet', () => {
const { result } = renderHook(() => useConnectedWallets())
const wallet: Wallet = {
walletType: 'injected',
account: '0x123',
}
act(() => {
result.current[1](wallet)
})
expect(result.current[0]).toEqual([wallet])
})
})

View File

@@ -0,0 +1,40 @@
import walletsReducer from './reducer'
import { Wallet } from './types'
describe('walletsSlice reducers', () => {
it('should add a connected wallet', () => {
const initialState = {
connectedWallets: [],
}
const wallet = {
address: '0x123',
chainId: 1,
}
const action = {
type: 'wallets/addConnectedWallet',
payload: wallet,
}
const expectedState = {
connectedWallets: [wallet],
}
expect(walletsReducer(initialState, action)).toEqual(expectedState)
})
it('should remove a connected wallet', () => {
const wallet: Wallet = {
walletType: 'metamask',
account: '0x123',
}
const initialState = {
connectedWallets: [wallet],
}
const action = {
type: 'wallets/removeConnectedWallet',
payload: wallet,
}
const expectedState = {
connectedWallets: [],
}
expect(walletsReducer(initialState, action)).toEqual(expectedState)
})
})

View File

@@ -0,0 +1,22 @@
import { ErrorEvent } from '@sentry/types'
import { filterKnownErrors } from './errors'
describe('filterKnownErrors', () => {
const ERROR = {} as ErrorEvent
it('propagates an error', () => {
expect(filterKnownErrors(ERROR, {})).toBe(ERROR)
})
it('filters block number polling errors', () => {
const originalException = new (class extends Error {
requestBody = JSON.stringify({ method: 'eth_blockNumber' })
})()
expect(filterKnownErrors(ERROR, { originalException })).toBe(null)
})
it('filters network change errors', () => {
const originalException = new Error('underlying network changed')
expect(filterKnownErrors(ERROR, { originalException })).toBe(null)
})
})

27
src/tracing/errors.ts Normal file
View File

@@ -0,0 +1,27 @@
import { ClientOptions, ErrorEvent, EventHint } from '@sentry/types'
/** Identifies ethers request errors (as thrown by {@type import(@ethersproject/web).fetchJson}). */
function isEthersRequestError(error: Error): error is Error & { requestBody: string } {
return 'requestBody' in error && typeof (error as unknown as Record<'requestBody', unknown>).requestBody === 'string'
}
/**
* Filters known (ignorable) errors out before sending them to Sentry.
* Intended as a {@link ClientOptions.beforeSend} callback. Returning null filters the error from Sentry.
*/
export const filterKnownErrors: Required<ClientOptions>['beforeSend'] = (event: ErrorEvent, hint: EventHint) => {
const error = hint.originalException
if (error instanceof Error) {
// ethers aggressively polls for block number, and it sometimes fails (whether spuriously or through rate-limiting).
// If block number polling, it should not be considered an exception.
if (isEthersRequestError(error)) {
const method = JSON.parse(error.requestBody).method
if (method === 'eth_blockNumber') return null
}
// If the error is a network change, it should not be considered an exception.
if (error.message.match(/underlying network changed/)) return null
}
return event
}

View File

@@ -7,6 +7,8 @@ import { SharedEventName } from '@uniswap/analytics-events'
import { isSentryEnabled } from 'utils/env'
import { getEnvName, isProductionEnv } from 'utils/env'
import { filterKnownErrors } from './errors'
export { trace } from './trace'
// Dump some metadata into the window to allow client verification.
@@ -17,13 +19,10 @@ const AMPLITUDE_DUMMY_KEY = '00000000000000000000000000000000'
export const STATSIG_DUMMY_KEY = 'client-0000000000000000000000000000000000000000000'
Sentry.init({
// General configuration:
dsn: process.env.REACT_APP_SENTRY_DSN,
release: process.env.REACT_APP_GIT_COMMIT_HASH,
environment: getEnvName(),
// Exception reporting configuration:
enabled: isSentryEnabled(),
// Performance tracing configuration:
tracesSampleRate: Number(process.env.REACT_APP_SENTRY_TRACES_SAMPLE_RATE ?? 0),
integrations: [
new BrowserTracing({
@@ -31,6 +30,7 @@ Sentry.init({
startTransactionOnPageLoad: true,
}),
],
beforeSend: filterKnownErrors,
})
initializeAnalytics(AMPLITUDE_DUMMY_KEY, OriginApplication.INTERFACE, {

View File

@@ -122,6 +122,9 @@ describe('prices', () => {
it('0 for undefined', () => {
expect(warningSeverity(undefined)).toEqual(0)
})
it('0 for negative', () => {
expect(warningSeverity(new Percent(-1))).toEqual(0)
})
it('correct for 0', () => {
expect(warningSeverity(new Percent(0))).toEqual(0)
})

View File

@@ -85,6 +85,12 @@ const IMPACT_TIERS = [
type WarningSeverity = 0 | 1 | 2 | 3 | 4
export function warningSeverity(priceImpact: Percent | undefined): WarningSeverity {
if (!priceImpact) return 0
// This function is used to calculate the Severity level for % changes in USD value and Price Impact.
// Price Impact is always an absolute value (conceptually always negative, but represented in code with a positive value)
// The USD value change can be positive or negative, and it follows the same standard as Price Impact (positive value is the typical case of a loss due to slippage).
// We don't want to return a warning level for a favorable/profitable change, so when the USD value change is negative we return 0.
// TODO (WEB-3133): Disambiguate Price Impact and USD value change, and flip the sign of USD Value change.
if (priceImpact.lessThan(0)) return 0
let impact: WarningSeverity = IMPACT_TIERS.length as WarningSeverity
for (const impactLevel of IMPACT_TIERS) {
if (impactLevel.lessThan(priceImpact)) return impact