Compare commits

...

24 Commits

Author SHA1 Message Date
Tina
2ffc8a0bdf chore: Remove SWAP_TRANSACTION_COMPLETED event (#6328)
remove swap completed event
2023-04-10 17:27:02 -07:00
eddie
5ec9cdc5c4 fix: redo MP drawer layout changes with mobile fixed (#6280)
* fix: redo MP drawer layout changes with mobile fixed

* fix: mobile fix and another test

* fix: comments
2023-04-10 12:53:58 -07:00
eddie
4d85775d90 fix: update CurrencyList unit tests (#6321)
* fix: testing snapshot updates

* fix: remove inline style and update snapshots tests
2023-04-10 12:18:54 -07:00
eddie
c1c59ca692 refactor: rename WalletDropdown to AccountDrawer (#6313)
* feat: rename WalletDropdown to Portfolio

* fix: update after rebase

* feat: rename from Portfolio to AccountDrawer

* fix: fix test
2023-04-10 11:26:05 -07:00
Zach Pomerantz
f29d97413e build: use env node for craco lint (#6311) 2023-04-10 10:05:22 -07:00
Zach Pomerantz
a078d94a38 chore: update pr template (#6314) 2023-04-10 10:05:06 -07:00
lynn
c9c3329bc3 fix: switch back buttons in mini portfolio (#6327)
switch back header
2023-04-10 12:57:31 -04:00
eddie
13d0b70fa8 fix: remove "Received Swap Quote" field from Connect Wallet event (#6316) 2023-04-10 09:18:07 -07:00
lynn
b852e4e64a feat: adding analytics for fiat on ramp buy button feature (#6272)
* init

* testing if it works

* wip

* tooltip still not working correctly

* modal still not triggered after initial buy click

* remove invalid import

* region check fixed

* add disabled buy button treatment

* simplify and fix toggle twice bug

* no more state mgmt bugs finally

* rename vars for clarity and add todos

* add feature flag, remove toast

* keep wallet drawer open upon repeated buy clicks

* remove from feature flag modal for now

* unused vars

* first round respond to tina comments

* respond to tina padding comments, fix padding in response to cal feedback

* last round tina comments

* init pending element names being added to analytics events repo

* update event names

* add tooltip delay requested by fred and cal

* middle of revisions, fiat buy flow readability wip

* hook logic refactor done + added basic unit test

* rename enum and add todo for unit tests

* mouseover tooltip disable properly

* fix mouseover tooltip not working, ensure dot working as expected, rename buyFiatClicked to buyFiatFlowCompleted

* change developer doc comment

* respond comments

* update snapshot test

* lint fix

* remove unnecessary changes
2023-04-07 13:02:07 -04:00
lynn
55bd3555be feat: Web 2996 add fiat on ramp buy flow to swap modal on the interface (#6240)
* init

* testing if it works

* wip

* tooltip still not working correctly

* modal still not triggered after initial buy click

* remove invalid import

* region check fixed

* add disabled buy button treatment

* simplify and fix toggle twice bug

* no more state mgmt bugs finally

* rename vars for clarity and add todos

* add feature flag, remove toast

* keep wallet drawer open upon repeated buy clicks

* remove from feature flag modal for now

* unused vars

* first round respond to tina comments

* respond to tina padding comments, fix padding in response to cal feedback

* last round tina comments

* add tooltip delay requested by fred and cal

* middle of revisions, fiat buy flow readability wip

* hook logic refactor done + added basic unit test

* rename enum and add todo for unit tests

* mouseover tooltip disable properly

* fix mouseover tooltip not working, ensure dot working as expected, rename buyFiatClicked to buyFiatFlowCompleted

* change developer doc comment

* respond comments

* update snapshot test

* comments

* small changes + unit tests

* dedup

* remove enzyme

* Remove unecessary line

* simplify

* more cleanup

* add missing await

* more comments

* more comment responses

* more comment responses

* delay show fixes and respond to comments

* fix logic for show

* remove tooltip delay, unit test changes

* Update src/components/Popover/index.tsx

Co-authored-by: Zach Pomerantz <zzmp@uniswap.org>

* remove delay on tooltip

* missed one

* Update src/components/swap/SwapBuyFiatButton.test.tsx

Co-authored-by: Tina <59578595+tinaszheng@users.noreply.github.com>

* comments

* .

* lint error

---------

Co-authored-by: Zach Pomerantz <zzmp@uniswap.org>
Co-authored-by: Tina <59578595+tinaszheng@users.noreply.github.com>
2023-04-06 17:06:50 -04:00
Zach Pomerantz
972a65066c fix: do not double-report i18n exception (#6308) 2023-04-06 13:18:01 -07:00
eddie
39a212f762 fix: catch json parse error in fetchTokenList (#6278)
* fix: catch json parse error in fetchTokenList

* fix: refactor fetchTokenList and add more tests

* fix: import in test

* fix: comments and names

* fix: comment format

* fix: comment formatting
2023-04-06 11:15:39 -07:00
Jack Short
c362f4fe39 chore: removing unnecessary data in assets query (#6301)
* chore: removing unnecessary data in assets query

* no smallimageurl

* disabling nft drawer loading initially on nft pages

* removed too much

* renaming

* fix missing rank
2023-04-06 14:04:16 -04:00
eddie
271ef580e1 fix: make token list version bump error quieter (#6271)
* fix: use console.debug for expected transient error

* fix: add tests

* fix: name and lints
2023-04-06 10:26:56 -07:00
Zach Pomerantz
81ced4cb8b test(e2e): configure cypress test retries for CI (#6305)
test: configure retries for cypress CI
2023-04-06 09:30:07 -07:00
cartcrom
ab214a8133 fix: only use local txs for current account (#6284)
* fix: only use local txs for current account

* refactor: remove unecessary try/catch

* fix: add back try/catch
2023-04-06 11:43:39 -04:00
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
90 changed files with 1668 additions and 836 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 | past_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, check and note so it is obvious to your reviewers and does not show up as an incomplete task. -->
<!-- eg - [x] Unit test N/A -->
- [ ] Unit test
- [ ] Integration/E2E test

View File

@@ -1,4 +1,4 @@
/* eslint-disable @typescript-eslint/no-var-requires */
/* eslint-env node */
const { VanillaExtractPlugin } = require('@vanilla-extract/webpack-plugin')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const { DefinePlugin } = require('webpack')

View File

@@ -6,6 +6,7 @@ export default defineConfig({
videoUploadOnPasses: false,
defaultCommandTimeout: 24000, // 2x average block time
chromeWebSecurity: false,
retries: { runMode: 2 },
e2e: {
setupNodeEvents(on, config) {
codeCoverageTask(on, config)

View File

@@ -26,7 +26,15 @@ describe('Landing Page', () => {
it('allows navigation to pool', () => {
cy.viewport(2000, 1600)
cy.visit('/swap')
cy.get(getTestSelector('pool-nav-link')).first().click()
cy.url().should('include', '/pools')
})
it('allows navigation to pool on mobile', () => {
cy.viewport('iphone-6')
cy.visit('/swap')
cy.get(getTestSelector('pool-nav-link')).last().click()
cy.url().should('include', '/pools')
})
})

View File

@@ -77,4 +77,12 @@ describe('Wallet Dropdown', () => {
cy.get(getTestSelector('theme-auto')).click()
cy.get(getTestSelector('wallet-header')).should('have.css', 'color', 'rgb(119, 128, 160)')
})
it('should use a bottom sheet and dismiss when on a mobile screen size', () => {
visit(true)
cy.viewport('iphone-6')
cy.get(getTestSelector('web3-status-connected')).click()
cy.root().click(15, 40)
cy.get(getTestSelector('wallet-settings')).should('not.be.visible')
})
})

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,
@@ -66,6 +70,7 @@
"@lingui/cli": "^3.9.0",
"@testing-library/jest-dom": "^5.16.4",
"@testing-library/react": "^13.1",
"@testing-library/user-event": "^14.4.3",
"@typechain/ethers-v5": "^7.0.0",
"@types/array.prototype.flat": "^1.2.1",
"@types/array.prototype.flatmap": "^1.2.2",
@@ -136,7 +141,7 @@
"@sentry/tracing": "^7.45.0",
"@types/react-window-infinite-loader": "^1.0.6",
"@uniswap/analytics": "^1.3.1",
"@uniswap/analytics-events": "^2.8.0",
"@uniswap/analytics-events": "^2.9.0",
"@uniswap/conedison": "^1.4.0",
"@uniswap/governance": "^1.0.2",
"@uniswap/liquidity-staker": "^1.0.2",

View File

@@ -30,13 +30,13 @@ import { useCloseModal, useFiatOnrampAvailability, useOpenModal, useToggleModal
import { ApplicationModal } from '../../state/application/reducer'
import { useUserHasAvailableClaim, useUserUnclaimedAmount } from '../../state/claim/hooks'
import StatusIcon from '../Identicon/StatusIcon'
import { useToggleWalletDrawer } from '.'
import { useToggleAccountDrawer } from '.'
import IconButton, { IconHoverText } from './IconButton'
import MiniPortfolio from './MiniPortfolio'
import { portfolioFadeInAnimation } from './MiniPortfolio/PortfolioRow'
const AuthenticatedHeaderWrapper = styled.div`
padding: 14px 12px 16px 16px;
padding: 20px 16px;
display: flex;
flex-direction: column;
flex: 1;
@@ -184,7 +184,7 @@ export default function AuthenticatedHeader({ account, openSettings }: { account
dispatch(updateSelectedWallet({ wallet: undefined }))
}, [connector, dispatch])
const toggleWalletDrawer = useToggleWalletDrawer()
const toggleWalletDrawer = useToggleAccountDrawer()
const navigateToProfile = useCallback(() => {
toggleWalletDrawer()
@@ -287,6 +287,16 @@ export default function AuthenticatedHeader({ account, openSettings }: { account
<LoadingBubble height="16px" width="100px" margin="4px 0 20px 0" />
</Column>
)}
{!shouldDisableNFTRoutes && (
<HeaderButton
data-testid="nft-view-self-nfts"
onClick={navigateToProfile}
size={ButtonSize.medium}
emphasis={ButtonEmphasis.medium}
>
<Trans>View and sell NFTs</Trans>
</HeaderButton>
)}
<HeaderButton
size={ButtonSize.medium}
emphasis={ButtonEmphasis.medium}
@@ -306,16 +316,6 @@ export default function AuthenticatedHeader({ account, openSettings }: { account
</>
)}
</HeaderButton>
{!shouldDisableNFTRoutes && (
<HeaderButton
data-testid="nft-view-self-nfts"
onClick={navigateToProfile}
size={ButtonSize.medium}
emphasis={ButtonEmphasis.medium}
>
<Trans>View and sell NFTs</Trans>
</HeaderButton>
)}
{Boolean(!fiatOnrampAvailable && fiatOnrampAvailabilityChecked) && (
<FiatOnrampNotAvailableText marginTop="8px">
<Trans>Not available in your region</Trans>

View File

@@ -1,7 +1,7 @@
import { t } from '@lingui/macro'
import { useAccountDrawer } from 'components/AccountDrawer'
import Column from 'components/Column'
import { LoadingBubble } from 'components/Tokens/loading'
import { useWalletDrawer } from 'components/WalletDropdown'
import { getYear, isSameDay, isSameMonth, isSameWeek, isSameYear } from 'date-fns'
import { TransactionStatus, useTransactionListQuery } from 'graphql/data/__generated__/types-and-hooks'
import { PollingInterval } from 'graphql/data/util'
@@ -98,10 +98,10 @@ function combineActivities(localMap: ActivityMap = {}, remoteMap: ActivityMap =
const lastFetchedAtom = atom<number | undefined>(0)
export function ActivityTab({ account }: { account: string }) {
const [drawerOpen, toggleWalletDrawer] = useWalletDrawer()
const [drawerOpen, toggleWalletDrawer] = useAccountDrawer()
const [lastFetched, setLastFetched] = useAtom(lastFetchedAtom)
const localMap = useLocalActivities()
const localMap = useLocalActivities(account)
const { data, loading, refetch } = useTransactionListQuery({
variables: { account },

View File

@@ -1,7 +1,7 @@
// jest unit tests for the parseLocalActivity function
import { SupportedChainId, Token, TradeType } from '@uniswap/sdk-core'
import { DAI, USDC_MAINNET } from 'constants/tokens'
import { SupportedChainId, Token, TradeType as MockTradeType } from '@uniswap/sdk-core'
import { DAI as MockDAI, USDC_MAINNET as MockUSDC_MAINNET } from 'constants/tokens'
import { TokenAddressMap } from 'state/lists/hooks'
import { WrappedTokenInfo } from 'state/lists/wrappedTokenInfo'
import {
@@ -10,23 +10,24 @@ import {
TransactionDetails,
TransactionType,
} from 'state/transactions/types'
import { renderHook } from 'test-utils'
import { parseLocalActivity } from './parseLocal'
import { parseLocalActivity, useLocalActivities } from './parseLocal'
const oneUSDCRaw = '1000000'
const oneDAIRaw = '1000000000000000000'
function buildSwapInfo(
type: TradeType,
function mockSwapInfo(
type: MockTradeType,
inputCurrency: Token,
inputCurrencyAmountRaw: string,
outputCurrency: Token,
outputCurrencyAmountRaw: string
): ExactInputSwapTransactionInfo | ExactOutputSwapTransactionInfo {
if (type === TradeType.EXACT_INPUT) {
if (type === MockTradeType.EXACT_INPUT) {
return {
type: TransactionType.SWAP,
tradeType: TradeType.EXACT_INPUT,
tradeType: MockTradeType.EXACT_INPUT,
inputCurrencyId: inputCurrency.address,
inputCurrencyAmountRaw,
outputCurrencyId: outputCurrency.address,
@@ -36,7 +37,7 @@ function buildSwapInfo(
} else {
return {
type: TransactionType.SWAP,
tradeType: TradeType.EXACT_OUTPUT,
tradeType: MockTradeType.EXACT_OUTPUT,
inputCurrencyId: inputCurrency.address,
expectedInputCurrencyAmountRaw: inputCurrencyAmountRaw,
maximumInputCurrencyAmountRaw: inputCurrencyAmountRaw,
@@ -46,7 +47,44 @@ function buildSwapInfo(
}
}
function buildTokenAddressMap(...tokens: WrappedTokenInfo[]): TokenAddressMap {
const mockAccount1 = '0x000000000000000000000000000000000000000001'
const mockAccount2 = '0x000000000000000000000000000000000000000002'
const mockChainId = SupportedChainId.MAINNET
jest.mock('../../../../state/transactions/hooks', () => {
return {
useMultichainTransactions: () => {
return [
[
{
info: mockSwapInfo(MockTradeType.EXACT_INPUT, MockUSDC_MAINNET, oneUSDCRaw, MockDAI, oneDAIRaw),
hash: '0x123',
from: mockAccount1,
} as TransactionDetails,
mockChainId,
],
[
{
info: mockSwapInfo(MockTradeType.EXACT_INPUT, MockUSDC_MAINNET, oneUSDCRaw, MockDAI, oneDAIRaw),
hash: '0x456',
from: mockAccount2,
} as TransactionDetails,
mockChainId,
],
[
{
info: mockSwapInfo(MockTradeType.EXACT_INPUT, MockUSDC_MAINNET, oneUSDCRaw, MockDAI, oneDAIRaw),
hash: '0x789',
from: mockAccount2,
} as TransactionDetails,
mockChainId,
],
]
},
}
})
function mockTokenAddressMap(...tokens: WrappedTokenInfo[]): TokenAddressMap {
return {
[SupportedChainId.MAINNET]: Object.fromEntries(tokens.map((token) => [token.address, { token }])),
}
@@ -55,27 +93,27 @@ function buildTokenAddressMap(...tokens: WrappedTokenInfo[]): TokenAddressMap {
describe('parseLocalActivity', () => {
it('returns swap activity fields with known tokens, exact input', () => {
const details = {
info: buildSwapInfo(TradeType.EXACT_INPUT, USDC_MAINNET, oneUSDCRaw, DAI, oneDAIRaw),
info: mockSwapInfo(MockTradeType.EXACT_INPUT, MockUSDC_MAINNET, oneUSDCRaw, MockDAI, oneDAIRaw),
receipt: {
transactionHash: '0x123',
status: 1,
},
} as TransactionDetails
const chainId = SupportedChainId.MAINNET
const tokens = buildTokenAddressMap(USDC_MAINNET as WrappedTokenInfo, DAI as WrappedTokenInfo)
const tokens = mockTokenAddressMap(MockUSDC_MAINNET as WrappedTokenInfo, MockDAI as WrappedTokenInfo)
expect(parseLocalActivity(details, chainId, tokens)).toEqual({
chainId: 1,
currencies: [USDC_MAINNET, DAI],
currencies: [MockUSDC_MAINNET, MockDAI],
descriptor: '1.00 USDC for 1.00 DAI',
hash: undefined,
receipt: {
id: '0x123',
info: {
type: 1,
tradeType: TradeType.EXACT_INPUT,
inputCurrencyId: USDC_MAINNET.address,
tradeType: MockTradeType.EXACT_INPUT,
inputCurrencyId: MockUSDC_MAINNET.address,
inputCurrencyAmountRaw: oneUSDCRaw,
outputCurrencyId: DAI.address,
outputCurrencyId: MockDAI.address,
expectedOutputCurrencyAmountRaw: oneDAIRaw,
minimumOutputCurrencyAmountRaw: oneDAIRaw,
},
@@ -91,28 +129,28 @@ describe('parseLocalActivity', () => {
it('returns swap activity fields with known tokens, exact output', () => {
const details = {
info: buildSwapInfo(TradeType.EXACT_OUTPUT, USDC_MAINNET, oneUSDCRaw, DAI, oneDAIRaw),
info: mockSwapInfo(MockTradeType.EXACT_OUTPUT, MockUSDC_MAINNET, oneUSDCRaw, MockDAI, oneDAIRaw),
receipt: {
transactionHash: '0x123',
status: 1,
},
} as TransactionDetails
const chainId = SupportedChainId.MAINNET
const tokens = buildTokenAddressMap(USDC_MAINNET as WrappedTokenInfo, DAI as WrappedTokenInfo)
const tokens = mockTokenAddressMap(MockUSDC_MAINNET as WrappedTokenInfo, MockDAI as WrappedTokenInfo)
expect(parseLocalActivity(details, chainId, tokens)).toEqual({
chainId: 1,
currencies: [USDC_MAINNET, DAI],
currencies: [MockUSDC_MAINNET, MockDAI],
descriptor: '1.00 USDC for 1.00 DAI',
hash: undefined,
receipt: {
id: '0x123',
info: {
type: 1,
tradeType: TradeType.EXACT_OUTPUT,
inputCurrencyId: USDC_MAINNET.address,
tradeType: MockTradeType.EXACT_OUTPUT,
inputCurrencyId: MockUSDC_MAINNET.address,
expectedInputCurrencyAmountRaw: oneUSDCRaw,
maximumInputCurrencyAmountRaw: oneUSDCRaw,
outputCurrencyId: DAI.address,
outputCurrencyId: MockDAI.address,
outputCurrencyAmountRaw: oneDAIRaw,
},
receipt: { status: 1, transactionHash: '0x123' },
@@ -127,7 +165,7 @@ describe('parseLocalActivity', () => {
it('returns swap activity fields with unknown tokens', () => {
const details = {
info: buildSwapInfo(TradeType.EXACT_INPUT, USDC_MAINNET, oneUSDCRaw, DAI, oneDAIRaw),
info: mockSwapInfo(MockTradeType.EXACT_INPUT, MockUSDC_MAINNET, oneUSDCRaw, MockDAI, oneDAIRaw),
receipt: {
transactionHash: '0x123',
status: 1,
@@ -144,10 +182,10 @@ describe('parseLocalActivity', () => {
id: '0x123',
info: {
type: 1,
tradeType: TradeType.EXACT_INPUT,
inputCurrencyId: USDC_MAINNET.address,
tradeType: MockTradeType.EXACT_INPUT,
inputCurrencyId: MockUSDC_MAINNET.address,
inputCurrencyAmountRaw: oneUSDCRaw,
outputCurrencyId: DAI.address,
outputCurrencyId: MockDAI.address,
expectedOutputCurrencyAmountRaw: oneDAIRaw,
minimumOutputCurrencyAmountRaw: oneDAIRaw,
},
@@ -160,4 +198,12 @@ describe('parseLocalActivity', () => {
title: 'Swapped',
})
})
it('only returns activity for the current account', () => {
const account1Activites = renderHook(() => useLocalActivities(mockAccount1)).result.current
const account2Activites = renderHook(() => useLocalActivities(mockAccount2)).result.current
expect(Object.values(account1Activites)).toHaveLength(1)
expect(Object.values(account2Activites)).toHaveLength(2)
})
})

View File

@@ -2,7 +2,6 @@ import { t } from '@lingui/macro'
import { formatCurrencyAmount } from '@uniswap/conedison/format'
import { Currency, CurrencyAmount, TradeType } from '@uniswap/sdk-core'
import { nativeOnChain } from '@uniswap/smart-order-router'
import { useWeb3React } from '@web3-react/core'
import { SupportedChainId } from 'constants/chains'
import { TransactionPartsFragment, TransactionStatus } from 'graphql/data/__generated__/types-and-hooks'
import { useMemo } from 'react'
@@ -135,71 +134,69 @@ export function parseLocalActivity(
chainId: SupportedChainId,
tokens: TokenAddressMap
): Activity | undefined {
const status = !details.receipt
? TransactionStatus.Pending
: details.receipt.status === 1 || details.receipt?.status === undefined
? TransactionStatus.Confirmed
: TransactionStatus.Failed
try {
const status = !details.receipt
? TransactionStatus.Pending
: details.receipt.status === 1 || details.receipt?.status === undefined
? TransactionStatus.Confirmed
: TransactionStatus.Failed
const receipt: TransactionPartsFragment | undefined = details.receipt
? {
id: details.receipt.transactionHash,
...details.receipt,
...details,
status,
}
: undefined
const receipt: TransactionPartsFragment | undefined = details.receipt
? {
id: details.receipt.transactionHash,
...details.receipt,
...details,
status,
}
: undefined
const defaultFields = {
hash: details.hash,
chainId,
title: getActivityTitle(details.info.type, status),
status,
timestamp: (details.confirmedTime ?? details.addedTime) / 1000,
receipt,
const defaultFields = {
hash: details.hash,
chainId,
title: getActivityTitle(details.info.type, status),
status,
timestamp: (details.confirmedTime ?? details.addedTime) / 1000,
receipt,
}
let additionalFields: Partial<Activity> = {}
const info = details.info
if (info.type === TransactionType.SWAP) {
additionalFields = parseSwap(info, chainId, tokens)
} else if (info.type === TransactionType.APPROVAL) {
additionalFields = parseApproval(info, chainId, tokens)
} else if (info.type === TransactionType.WRAP) {
additionalFields = parseWrap(info, chainId, status)
} else if (
info.type === TransactionType.ADD_LIQUIDITY_V3_POOL ||
info.type === TransactionType.REMOVE_LIQUIDITY_V3 ||
info.type === TransactionType.ADD_LIQUIDITY_V2_POOL
) {
additionalFields = parseLP(info, chainId, tokens)
} else if (info.type === TransactionType.COLLECT_FEES) {
additionalFields = parseCollectFees(info, chainId, tokens)
} else if (info.type === TransactionType.MIGRATE_LIQUIDITY_V3 || info.type === TransactionType.CREATE_V3_POOL) {
additionalFields = parseMigrateCreateV3(info, chainId, tokens)
}
return { ...defaultFields, ...additionalFields }
} catch (error) {
console.debug(`Failed to parse transaction ${details.hash}`, error)
return undefined
}
let additionalFields: Partial<Activity> = {}
const info = details.info
if (info.type === TransactionType.SWAP) {
additionalFields = parseSwap(info, chainId, tokens)
} else if (info.type === TransactionType.APPROVAL) {
additionalFields = parseApproval(info, chainId, tokens)
} else if (info.type === TransactionType.WRAP) {
additionalFields = parseWrap(info, chainId, status)
} else if (
info.type === TransactionType.ADD_LIQUIDITY_V3_POOL ||
info.type === TransactionType.REMOVE_LIQUIDITY_V3 ||
info.type === TransactionType.ADD_LIQUIDITY_V2_POOL
) {
additionalFields = parseLP(info, chainId, tokens)
} else if (info.type === TransactionType.COLLECT_FEES) {
additionalFields = parseCollectFees(info, chainId, tokens)
} else if (info.type === TransactionType.MIGRATE_LIQUIDITY_V3 || info.type === TransactionType.CREATE_V3_POOL) {
additionalFields = parseMigrateCreateV3(info, chainId, tokens)
}
return { ...defaultFields, ...additionalFields }
}
export function useLocalActivities(): ActivityMap | undefined {
export function useLocalActivities(account: string): ActivityMap {
const allTransactions = useMultichainTransactions()
const { chainId } = useWeb3React()
const tokens = useCombinedActiveList()
return useMemo(
() =>
chainId
? allTransactions.reduce((acc: { [hash: string]: Activity }, [transaction, chainId]) => {
try {
const localActivity = parseLocalActivity(transaction, chainId, tokens)
if (localActivity) acc[localActivity.hash] = localActivity
} catch (error) {
console.error('Failed to parse local activity', transaction)
}
return acc
}, {})
: undefined,
[allTransactions, chainId, tokens]
)
return useMemo(() => {
const activityByHash: ActivityMap = {}
for (const [transaction, chainId] of allTransactions) {
if (transaction.from !== account) continue
activityByHash[transaction.hash] = parseLocalActivity(transaction, chainId, tokens)
}
return activityByHash
}, [account, allTransactions, tokens])
}

View File

@@ -1,8 +1,8 @@
import { sendAnalyticsEvent, useTrace } from '@uniswap/analytics'
import { InterfaceElementName, SharedEventName } from '@uniswap/analytics-events'
import { useToggleAccountDrawer } from 'components/AccountDrawer'
import Column from 'components/Column'
import Row from 'components/Row'
import { useToggleWalletDrawer } from 'components/WalletDropdown'
import { Box } from 'nft/components/Box'
import { NftCard } from 'nft/components/card'
import { detailsHref } from 'nft/components/card/utils'
@@ -46,7 +46,7 @@ export function NFT({
mediaShouldBePlaying: boolean
setCurrentTokenPlayingMedia: (tokenId: string | undefined) => void
}) {
const toggleWalletDrawer = useToggleWalletDrawer()
const toggleWalletDrawer = useToggleAccountDrawer()
const navigate = useNavigate()
const trace = useTrace()

View File

@@ -5,13 +5,22 @@ import { useState } from 'react'
import InfiniteScroll from 'react-infinite-scroll-component'
import styled from 'styled-components/macro'
import { useToggleWalletDrawer } from '..'
import { useAccountDrawer } from '..'
import { DEFAULT_NFT_QUERY_AMOUNT } from './constants'
import { NFT } from './NFT'
export default function NFTs({ account }: { account: string }) {
const { walletAssets, loading, hasNext, loadMore } = useNftBalance(account, [], [], DEFAULT_NFT_QUERY_AMOUNT)
const toggleWalletDrawer = useToggleWalletDrawer()
const [walletDrawerOpen, toggleWalletDrawer] = useAccountDrawer()
const { walletAssets, loading, hasNext, loadMore } = useNftBalance(
account,
[],
[],
DEFAULT_NFT_QUERY_AMOUNT,
undefined,
undefined,
undefined,
!walletDrawerOpen
)
const [currentTokenPlayingMedia, setCurrentTokenPlayingMedia] = useState<string | undefined>()

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

@@ -4,9 +4,9 @@ import { BrowserEvent, InterfaceElementName, SharedEventName } from '@uniswap/an
import { formatNumber, NumberType } from '@uniswap/conedison/format'
import { Position } from '@uniswap/v3-sdk'
import { useWeb3React } from '@web3-react/core'
import { useToggleAccountDrawer } from 'components/AccountDrawer'
import Row from 'components/Row'
import { MouseoverTooltip } from 'components/Tooltip'
import { useToggleWalletDrawer } from 'components/WalletDropdown'
import { EmptyWalletModule } from 'nft/components/profile/view/EmptyWalletContent'
import { useCallback, useMemo, useReducer } from 'react'
import { useNavigate } from 'react-router-dom'
@@ -33,7 +33,7 @@ export default function Pools({ account }: { account: string }) {
return [openPositions, closedPositions]
}, [positions])
const toggleWalletDrawer = useToggleWalletDrawer()
const toggleWalletDrawer = useToggleAccountDrawer()
if (!positions || loading) {
return <PortfolioSkeleton />
@@ -93,7 +93,7 @@ function PositionListItem({ positionInfo }: { positionInfo: PositionInfo }) {
const liquidityValue = calculcateLiquidityValue(priceA, priceB, position)
const navigate = useNavigate()
const toggleWalletDrawer = useToggleWalletDrawer()
const toggleWalletDrawer = useToggleAccountDrawer()
const { chainId: walletChainId, connector } = useWeb3React()
const onClick = useCallback(async () => {
if (walletChainId !== chainId) await switchChain(connector, chainId)
@@ -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

@@ -12,7 +12,7 @@ import { useNavigate } from 'react-router-dom'
import styled from 'styled-components/macro'
import { EllipsisStyle, ThemedText } from 'theme'
import { useToggleWalletDrawer } from '..'
import { useToggleAccountDrawer } from '..'
import { PortfolioArrow } from '../AuthenticatedHeader'
import { hideSmallBalancesAtom } from '../SmallBalanceToggle'
import { ExpandoRow } from './ExpandoRow'
@@ -26,7 +26,7 @@ function meetsThreshold(tokenBalance: TokenBalance, hideSmallBalances: boolean)
}
export default function Tokens({ account }: { account: string }) {
const toggleWalletDrawer = useToggleWalletDrawer()
const toggleWalletDrawer = useToggleAccountDrawer()
const hideSmallBalances = useAtomValue(hideSmallBalancesAtom)
const [showHiddenTokens, setShowHiddenTokens] = useState(false)
@@ -96,7 +96,7 @@ function TokenRow({ token, quantity, denominatedValue, tokenProjectMarket }: Tok
const percentChange = tokenProjectMarket?.pricePercentChange?.value ?? 0
const navigate = useNavigate()
const toggleWalletDrawer = useToggleWalletDrawer()
const toggleWalletDrawer = useToggleAccountDrawer()
const navigateToTokenDetails = useCallback(async () => {
navigate(getTokenDetailsURL(token))
toggleWalletDrawer()

View File

@@ -5,7 +5,7 @@ import { PropsWithChildren, useCallback, useEffect, useMemo, useState } from 're
import { useAllTransactions } from 'state/transactions/hooks'
import { TransactionDetails } from 'state/transactions/types'
import { useWalletDrawer } from '.'
import { useAccountDrawer } from '.'
const isTxPending = (tx: TransactionDetails) => !tx.receipt
function wasPending(previousTxs: { [hash: string]: TransactionDetails | undefined }, current: TransactionDetails) {
@@ -39,7 +39,7 @@ function useHasUpdatedTx() {
export default function PrefetchBalancesWrapper({ children }: PropsWithChildren) {
const { account } = useWeb3React()
const [prefetchPortfolioBalances] = usePortfolioBalancesLazyQuery()
const [drawerOpen] = useWalletDrawer()
const [drawerOpen] = useAccountDrawer()
const [hasUnfetchedBalances, setHasUnfetchedBalances] = useState(true)
const fetchBalances = useCallback(() => {

View File

@@ -18,18 +18,18 @@ const DRAWER_MARGIN = '8px'
const DRAWER_OFFSET = '10px'
const DRAWER_TOP_MARGIN_MOBILE_WEB = '72px'
const walletDrawerOpenAtom = atom(false)
const accountDrawerOpenAtom = atom(false)
export function useToggleWalletDrawer() {
const updateWalletDrawerOpen = useUpdateAtom(walletDrawerOpenAtom)
export function useToggleAccountDrawer() {
const updateAccountDrawerOpen = useUpdateAtom(accountDrawerOpenAtom)
return useCallback(() => {
updateWalletDrawerOpen((open) => !open)
}, [updateWalletDrawerOpen])
updateAccountDrawerOpen((open) => !open)
}, [updateAccountDrawerOpen])
}
export function useWalletDrawer(): [boolean, () => void] {
const walletDrawerOpen = useAtomValue(walletDrawerOpenAtom)
return [walletDrawerOpen, useToggleWalletDrawer()]
export function useAccountDrawer(): [boolean, () => void] {
const accountDrawerOpen = useAtomValue(accountDrawerOpenAtom)
return [accountDrawerOpen, useToggleAccountDrawer()]
}
const ScrimBackground = styled.div<{ open: boolean }>`
@@ -63,7 +63,7 @@ const Scrim = ({ onClick, open }: { onClick: () => void; open: boolean }) => {
return <ScrimBackground onClick={onClick} open={open} />
}
const WalletDropdownScrollWrapper = styled.div`
const AccountDrawerScrollWrapper = styled.div`
overflow: hidden;
&:hover {
overflow-y: auto;
@@ -76,32 +76,45 @@ const WalletDropdownScrollWrapper = styled.div`
border-radius: 12px;
`
const WalletDropdownWrapper = styled.div<{ open: boolean }>`
const Container = styled.div`
display: flex;
flex-direction: row;
height: calc(100% - 2 * ${DRAWER_MARGIN});
overflow: hidden;
position: fixed;
right: ${DRAWER_MARGIN};
top: ${DRAWER_MARGIN};
right: ${({ open }) => (open ? DRAWER_MARGIN : '-' + DRAWER_WIDTH)};
z-index: ${Z_INDEX.fixed};
overflow: hidden;
@media only screen and (max-width: ${({ theme }) => `${theme.breakpoint.sm}px`}) {
top: 100%;
left: 0;
right: 0;
width: 100%;
overflow: visible;
}
`
height: calc(100% - 2 * ${DRAWER_MARGIN});
const AccountDrawerWrapper = styled.div<{ open: boolean }>`
margin-right: ${({ open }) => (open ? 0 : '-' + DRAWER_WIDTH)};
height: 100%;
overflow: hidden;
@media only screen and (max-width: ${({ theme }) => `${theme.breakpoint.sm}px`}) {
z-index: ${Z_INDEX.modal};
top: unset;
left: 0;
right: 0;
bottom: ${({ open }) => (open ? 0 : `calc(-1 * (100% - ${DRAWER_TOP_MARGIN_MOBILE_WEB}))`)};
position: absolute;
margin-right: 0;
top: ${({ open }) => (open ? `calc(-1 * (100% - ${DRAWER_TOP_MARGIN_MOBILE_WEB}))` : 0)};
width: 100%;
height: calc(100% - ${DRAWER_TOP_MARGIN_MOBILE_WEB});
border-bottom-right-radius: 0px;
border-bottom-left-radius: 0px;
box-shadow: unset;
transition: top ${({ theme }) => theme.transition.duration.medium};
}
@media screen and (min-width: 1440px) {
right: ${({ open }) => (open ? DRAWER_MARGIN : '-' + DRAWER_WIDTH_XL)};
margin-right: ${({ open }) => (open ? 0 : `-${DRAWER_WIDTH_XL}`)};
width: ${DRAWER_WIDTH_XL};
}
@@ -112,8 +125,7 @@ const WalletDropdownWrapper = styled.div<{ open: boolean }>`
border: ${({ theme }) => `1px solid ${theme.backgroundOutline}`};
box-shadow: ${({ theme }) => theme.deepShadow};
transition: right ${({ theme }) => theme.transition.duration.medium},
bottom ${({ theme }) => theme.transition.duration.medium};
transition: margin-right ${({ theme }) => theme.transition.duration.medium};
`
const CloseIcon = styled(ChevronsRight).attrs({ size: 24 })`
@@ -123,30 +135,24 @@ const CloseIcon = styled(ChevronsRight).attrs({ size: 24 })`
const CloseDrawer = styled.div`
${ClickableStyle}
cursor: pointer;
height: calc(100% - 2 * ${DRAWER_MARGIN});
position: fixed;
right: calc(${DRAWER_MARGIN} + ${DRAWER_WIDTH} - ${DRAWER_OFFSET});
top: 4px;
z-index: ${Z_INDEX.dropdown};
height: 100%;
// 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;
transition: ${({ theme }) =>
`${theme.transition.duration.medium} ${theme.transition.timing.ease} background-color, ${theme.transition.duration.medium} ${theme.transition.timing.ease} margin`};
&:hover {
margin: 0 -4px 0 0;
z-index: -1;
margin: 0 -8px 0 0;
background-color: ${({ theme }) => theme.stateOverlayHover};
}
@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() {
const [walletDrawerOpen, toggleWalletDrawer] = useWalletDrawer()
function AccountDrawer() {
const [walletDrawerOpen, toggleWalletDrawer] = useAccountDrawer()
const scrollRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!walletDrawerOpen) {
@@ -187,7 +193,7 @@ function WalletDropdown() {
}, [walletDrawerOpen, toggleWalletDrawer])
return (
<>
<Container>
{walletDrawerOpen && (
<TraceEvent
events={[BrowserEvent.onClick]}
@@ -200,14 +206,14 @@ function WalletDropdown() {
</TraceEvent>
)}
<Scrim onClick={toggleWalletDrawer} open={walletDrawerOpen} />
<WalletDropdownWrapper open={walletDrawerOpen}>
<AccountDrawerWrapper open={walletDrawerOpen}>
{/* id used for child InfiniteScrolls to reference when it has reached the bottom of the component */}
<WalletDropdownScrollWrapper ref={scrollRef} id="wallet-dropdown-scroll-wrapper">
<AccountDrawerScrollWrapper ref={scrollRef} id="wallet-dropdown-scroll-wrapper">
<DefaultMenu />
</WalletDropdownScrollWrapper>
</WalletDropdownWrapper>
</>
</AccountDrawerScrollWrapper>
</AccountDrawerWrapper>
</Container>
)
}
export default WalletDropdown
export default AccountDrawer

View File

@@ -1,8 +1,8 @@
import { Trans } from '@lingui/macro'
import { useAccountDrawer } from 'components/AccountDrawer'
import { DownloadButton, LearnMoreButton } from 'components/AccountDrawer/DownloadButton'
import { AutoColumn } from 'components/Column'
import Row, { RowBetween } from 'components/Row'
import { useWalletDrawer } from 'components/WalletDropdown'
import { DownloadButton, LearnMoreButton } from 'components/WalletDropdown/DownloadButton'
import { X } from 'react-feather'
import { useLocation } from 'react-router-dom'
import { useHideUniswapWalletBanner } from 'state/user/hooks'
@@ -66,7 +66,7 @@ const StyledXButton = styled(X)`
export default function UniswapWalletBanner() {
const [hideUniswapWalletBanner, toggleHideUniswapWalletBanner] = useHideUniswapWalletBanner()
const [walletDrawerOpen] = useWalletDrawer()
const [walletDrawerOpen] = useAccountDrawer()
const theme = useTheme()

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,8 +1,8 @@
import { useWeb3React } from '@web3-react/core'
import { parseLocalActivity } from 'components/AccountDrawer/MiniPortfolio/Activity/parseLocal'
import { PortfolioLogo } from 'components/AccountDrawer/MiniPortfolio/PortfolioLogo'
import PortfolioRow from 'components/AccountDrawer/MiniPortfolio/PortfolioRow'
import Column from 'components/Column'
import { parseLocalActivity } from 'components/WalletDropdown/MiniPortfolio/Activity/parseLocal'
import { PortfolioLogo } from 'components/WalletDropdown/MiniPortfolio/PortfolioLogo'
import PortfolioRow from 'components/WalletDropdown/MiniPortfolio/PortfolioRow'
import useENSName from 'hooks/useENSName'
import { useCombinedActiveList } from 'state/lists/hooks'
import { useTransaction } from 'state/transactions/hooks'

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

@@ -1,425 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders currency rows correctly when currencies list is non-empty 1`] = `
<DocumentFragment>
.c0 {
box-sizing: border-box;
margin: 0;
min-width: 0;
}
.c1 {
width: 100%;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
padding: 0;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-pack: start;
-webkit-justify-content: flex-start;
-ms-flex-pack: start;
justify-content: flex-start;
}
.c2 {
-webkit-box-pack: justify;
-webkit-justify-content: space-between;
-ms-flex-pack: justify;
justify-content: space-between;
}
.c11 {
width: -webkit-fit-content;
width: -moz-fit-content;
width: fit-content;
}
.c10 {
color: #98A1C0;
}
.c8 {
margin-left: 4px;
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;
}
.c9 {
width: 1em;
height: 1em;
color: #98A1C0;
}
.c4 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-flex-direction: column;
-ms-flex-direction: column;
flex-direction: column;
-webkit-box-pack: start;
-webkit-justify-content: flex-start;
-ms-flex-pack: start;
justify-content: flex-start;
}
.c5 {
display: grid;
grid-auto-rows: auto;
}
.c3 {
padding: 4px 20px;
height: 56px;
display: grid;
grid-template-columns: auto minmax(auto,1fr) auto minmax(0,72px);
grid-gap: 16px;
cursor: pointer;
opacity: 1;
}
.c3:hover {
background-color: #98A1C014;
}
.c6 {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.c7 {
margin-left: 0.3em;
}
<div
style="padding-right: 4px;"
>
<div
class="CurrencyList_scrollbarStyle__1pi21y70"
style="position: relative; height: 10px; width: 100%; overflow: auto; will-change: transform; direction: ltr;"
>
<div
style="height: 168px; width: 100%;"
>
<div
class="c0 c1 c2 c3 token-item-0x6B175474E89094C44Da98b954EedeAC495271d0F"
style="position: absolute; left: 0px; top: 0px; height: 56px; width: 100%;"
tabindex="0"
>
<div
class="c4"
>
CurrencyLogo currency=DAI
</div>
<div
class="c5"
style="opacity: 1;"
>
<div
class="c0 c1"
>
<div
class="c6 css-vurnku"
title="Dai Stablecoin"
>
Dai Stablecoin
</div>
<div
class="c7"
>
<div
class="c8"
>
<svg
class="c9"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"
/>
<line
x1="12"
x2="12"
y1="9"
y2="13"
/>
<line
x1="12"
x2="12.01"
y1="17"
y2="17"
/>
</svg>
</div>
</div>
</div>
<div
class="c10 css-yfjwjl"
>
DAI
</div>
</div>
<div
class="c4"
>
<div
class="c0 c1 c11"
style="justify-self: flex-end;"
/>
</div>
</div>
<div
class="c0 c1 c2 c3 token-item-0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
style="position: absolute; left: 0px; top: 56px; height: 56px; width: 100%;"
tabindex="0"
>
<div
class="c4"
>
CurrencyLogo currency=USDC
</div>
<div
class="c5"
style="opacity: 1;"
>
<div
class="c0 c1"
>
<div
class="c6 css-vurnku"
title="USD//C"
>
USD//C
</div>
<div
class="c7"
>
<div
class="c8"
>
<svg
class="c9"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"
/>
<line
x1="12"
x2="12"
y1="9"
y2="13"
/>
<line
x1="12"
x2="12.01"
y1="17"
y2="17"
/>
</svg>
</div>
</div>
</div>
<div
class="c10 css-yfjwjl"
>
USDC
</div>
</div>
<div
class="c4"
>
<div
class="c0 c1 c11"
style="justify-self: flex-end;"
/>
</div>
</div>
<div
class="c0 c1 c2 c3 token-item-0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599"
style="position: absolute; left: 0px; top: 112px; height: 56px; width: 100%;"
tabindex="0"
>
<div
class="c4"
>
CurrencyLogo currency=WBTC
</div>
<div
class="c5"
style="opacity: 1;"
>
<div
class="c0 c1"
>
<div
class="c6 css-vurnku"
title="Wrapped BTC"
>
Wrapped BTC
</div>
<div
class="c7"
>
<div
class="c8"
>
<svg
class="c9"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"
/>
<line
x1="12"
x2="12"
y1="9"
y2="13"
/>
<line
x1="12"
x2="12.01"
y1="17"
y2="17"
/>
</svg>
</div>
</div>
</div>
<div
class="c10 css-yfjwjl"
>
WBTC
</div>
</div>
<div
class="c4"
>
<div
class="c0 c1 c11"
style="justify-self: flex-end;"
/>
</div>
</div>
</div>
</div>
</div>
</DocumentFragment>
`;
exports[`renders loading rows when isLoading is true 1`] = `
<DocumentFragment>
.c0 {
display: grid;
}
.c0 > div {
-webkit-animation: fAQEyV 1.5s infinite;
animation: fAQEyV 1.5s infinite;
-webkit-animation-fill-mode: both;
animation-fill-mode: both;
background: linear-gradient( to left,#F5F6FC 25%,#E8ECFB 50%,#F5F6FC 75% );
background-size: 400%;
border-radius: 12px;
height: 2.4em;
will-change: background-position;
}
.c1 {
grid-column-gap: 0.5em;
grid-template-columns: repeat(12,1fr);
max-width: 960px;
padding: 12px 20px;
}
.c1 > div:nth-child(4n + 1) {
grid-column: 1 / 8;
height: 1em;
margin-bottom: 0.25em;
}
.c1 > div:nth-child(4n + 2) {
grid-column: 12;
height: 1em;
margin-top: 0.25em;
}
.c1 > div:nth-child(4n + 3) {
grid-column: 1 / 4;
height: 0.75em;
}
<div
style="padding-right: 4px;"
>
<div
class="CurrencyList_scrollbarStyle__1pi21y70"
style="position: relative; height: 10px; width: 100%; overflow: auto; will-change: transform; direction: ltr;"
>
<div
style="height: 560px; width: 100%;"
>
<div
class="c0 c1"
>
<div />
<div />
<div />
</div>
<div
class="c0 c1"
>
<div />
<div />
<div />
</div>
<div
class="c0 c1"
>
<div />
<div />
<div />
</div>
</div>
</div>
</div>
</DocumentFragment>
`;

View File

@@ -1,3 +1,4 @@
import { screen } from '@testing-library/react'
import { Currency, CurrencyAmount as mockCurrencyAmount, Token as mockToken } from '@uniswap/sdk-core'
import { DAI, USDC_MAINNET, WBTC } from 'constants/tokens'
import * as mockJSBI from 'jsbi'
@@ -25,11 +26,11 @@ jest.mock(
jest.mock('@web3-react/core', () => {
const web3React = jest.requireActual('@web3-react/core')
return {
...web3React,
useWeb3React: () => ({
account: '123',
isActive: true,
}),
...web3React,
}
})
@@ -42,37 +43,38 @@ jest.mock('../../../state/connection/hooks', () => {
})
it('renders loading rows when isLoading is true', () => {
const { asFragment } = render(
const component = render(
<CurrencyList
height={10}
currencies={[]}
otherListTokens={[]}
selectedCurrency={null}
onCurrencySelect={noOp}
showImportView={noOp}
setImportToken={noOp}
isLoading={true}
searchQuery=""
isAddressSearch=""
/>
)
expect(asFragment()).toMatchSnapshot()
expect(component.findByTestId('loading-rows')).toBeTruthy()
expect(screen.queryByText('Wrapped BTC')).not.toBeInTheDocument()
expect(screen.queryByText('DAI')).not.toBeInTheDocument()
expect(screen.queryByText('USDC')).not.toBeInTheDocument()
})
it('renders currency rows correctly when currencies list is non-empty', () => {
const { asFragment } = render(
render(
<CurrencyList
height={10}
currencies={[DAI, USDC_MAINNET, WBTC]}
otherListTokens={[]}
selectedCurrency={null}
onCurrencySelect={noOp}
showImportView={noOp}
setImportToken={noOp}
isLoading={false}
searchQuery=""
isAddressSearch=""
/>
)
expect(asFragment()).toMatchSnapshot()
expect(screen.getByText('Wrapped BTC')).toBeInTheDocument()
expect(screen.getByText('DAI')).toBeInTheDocument()
expect(screen.getByText('USDC')).toBeInTheDocument()
})

View File

@@ -20,7 +20,7 @@ import CurrencyLogo from '../../Logo/CurrencyLogo'
import Row, { RowFixed } from '../../Row'
import { MouseoverTooltip } from '../../Tooltip'
import { LoadingRows, MenuItem } from '../styleds'
import * as styles from './index.css'
import { scrollbarStyle } from './index.css'
function currencyKey(currency: Currency): string {
return currency.isToken ? currency.address : 'ETHER'
@@ -65,6 +65,10 @@ const WarningContainer = styled.div`
margin-left: 0.3em;
`
const ListWrapper = styled.div`
padding-right: 0.25rem;
`
function Balance({ balance }: { balance: CurrencyAmount<Currency> }) {
return <StyledBalanceText title={balance.toExact()}>{balance.toSignificant(4)}</StyledBalanceText>
}
@@ -212,7 +216,7 @@ export const formatAnalyticsEventProperties = (
})
const LoadingRow = () => (
<LoadingRows>
<LoadingRows data-testid="loading-rows">
<div />
<div />
<div />
@@ -290,10 +294,10 @@ export default function CurrencyList({
}, [])
return (
<div style={{ paddingRight: '4px' }}>
<ListWrapper>
{isLoading ? (
<FixedSizeList
className={styles.scrollbarStyle}
className={scrollbarStyle}
height={height}
ref={fixedListRef as any}
width="100%"
@@ -305,7 +309,7 @@ export default function CurrencyList({
</FixedSizeList>
) : (
<FixedSizeList
className={styles.scrollbarStyle}
className={scrollbarStyle}
height={height}
ref={fixedListRef as any}
width="100%"
@@ -317,6 +321,6 @@ export default function CurrencyList({
{Row}
</FixedSizeList>
)}
</div>
</ListWrapper>
)
}

View File

@@ -1,13 +1,17 @@
import { transparentize } from 'polished'
import { ReactNode, useCallback, useEffect, useState } from 'react'
import { ReactNode, useEffect, useState } from 'react'
import styled from 'styled-components/macro'
import Popover, { PopoverProps } from '../Popover'
// TODO(WEB-3163): migrate noops throughout web to a shared util file.
const noop = () => null
export const TooltipContainer = styled.div`
max-width: 256px;
cursor: default;
padding: 0.6rem 1rem;
pointer-events: auto;
color: ${({ theme }) => theme.textPrimary};
font-weight: 400;
@@ -25,7 +29,6 @@ interface TooltipProps extends Omit<PopoverProps, 'content'> {
text: ReactNode
open?: () => void
close?: () => void
noOp?: () => void
disableHover?: boolean // disable the hover and content display
timeout?: number
}
@@ -33,17 +36,19 @@ interface TooltipProps extends Omit<PopoverProps, 'content'> {
interface TooltipContentProps extends Omit<PopoverProps, 'content'> {
content: ReactNode
onOpen?: () => void
open?: () => void
close?: () => void
// whether to wrap the content in a `TooltipContainer`
wrap?: boolean
disableHover?: boolean // disable the hover and content display
}
export default function Tooltip({ text, open, close, noOp, disableHover, ...rest }: TooltipProps) {
export default function Tooltip({ text, open, close, disableHover, ...rest }: TooltipProps) {
return (
<Popover
content={
text && (
<TooltipContainer onMouseEnter={disableHover ? noOp : open} onMouseLeave={disableHover ? noOp : close}>
<TooltipContainer onMouseEnter={disableHover ? noop : open} onMouseLeave={disableHover ? noop : close}>
{text}
</TooltipContainer>
)
@@ -53,15 +58,28 @@ export default function Tooltip({ text, open, close, noOp, disableHover, ...rest
)
}
function TooltipContent({ content, wrap = false, ...rest }: TooltipContentProps) {
return <Popover content={wrap ? <TooltipContainer>{content}</TooltipContainer> : content} {...rest} />
function TooltipContent({ content, wrap = false, open, close, disableHover, ...rest }: TooltipContentProps) {
return (
<Popover
content={
wrap ? (
<TooltipContainer onMouseEnter={disableHover ? noop : open} onMouseLeave={disableHover ? noop : close}>
{content}
</TooltipContainer>
) : (
content
)
}
{...rest}
/>
)
}
/** Standard text tooltip. */
export function MouseoverTooltip({ text, disableHover, children, timeout, ...rest }: Omit<TooltipProps, 'show'>) {
const [show, setShow] = useState(false)
const open = useCallback(() => text && setShow(true), [text, setShow])
const close = useCallback(() => setShow(false), [setShow])
const open = () => text && setShow(true)
const close = () => setShow(false)
useEffect(() => {
if (show && timeout) {
@@ -76,18 +94,16 @@ export function MouseoverTooltip({ text, disableHover, children, timeout, ...res
return
}, [timeout, show])
const noOp = () => null
return (
<Tooltip
{...rest}
open={open}
close={close}
noOp={noOp}
disableHover={disableHover}
show={show}
text={disableHover ? null : text}
>
<div onMouseEnter={disableHover ? noOp : open} onMouseLeave={disableHover || timeout ? noOp : close}>
<div onMouseEnter={disableHover ? noop : open} onMouseLeave={disableHover || timeout ? noop : close}>
{children}
</div>
</Tooltip>
@@ -103,18 +119,23 @@ export function MouseoverTooltipContent({
...rest
}: Omit<TooltipContentProps, 'show'>) {
const [show, setShow] = useState(false)
const open = useCallback(() => {
const open = () => {
setShow(true)
openCallback?.()
}, [openCallback])
const close = useCallback(() => setShow(false), [setShow])
}
const close = () => {
setShow(false)
}
return (
<TooltipContent {...rest} show={!disableHover && show} content={disableHover ? null : content}>
<div
style={{ display: 'inline-block', lineHeight: 0, padding: '0.25rem' }}
onMouseEnter={open}
onMouseLeave={close}
>
<TooltipContent
{...rest}
open={open}
close={close}
show={!disableHover && show}
content={disableHover ? null : content}
>
<div onMouseEnter={open} onMouseLeave={close}>
{children}
</div>
</TooltipContent>

View File

@@ -1,9 +1,9 @@
import { useWeb3React } from '@web3-react/core'
import UniwalletModal from 'components/AccountDrawer/UniwalletModal'
import UniswapWalletBanner from 'components/Banner/UniswapWalletBanner'
import AddressClaimModal from 'components/claim/AddressClaimModal'
import ConnectedAccountBlocked from 'components/ConnectedAccountBlocked'
import FiatOnrampModal from 'components/FiatOnrampModal'
import UniwalletModal from 'components/WalletDropdown/UniwalletModal'
import useAccountRiskCheck from 'hooks/useAccountRiskCheck'
import { lazy } from 'react'
import { useModalIsOpen, useToggleModal } from 'state/application/hooks'

View File

@@ -2,11 +2,11 @@ import { sendAnalyticsEvent, user } from '@uniswap/analytics'
import { CustomUserProperties, InterfaceEventName, WalletConnectionResult } from '@uniswap/analytics-events'
import { getWalletMeta } from '@uniswap/conedison/provider/meta'
import { useWeb3React } from '@web3-react/core'
import { useAccountDrawer } from 'components/AccountDrawer'
import IconButton from 'components/AccountDrawer/IconButton'
import { sendEvent } from 'components/analytics'
import { AutoColumn } from 'components/Column'
import { AutoRow } from 'components/Row'
import { useWalletDrawer } from 'components/WalletDropdown'
import IconButton from 'components/WalletDropdown/IconButton'
import { Connection, ConnectionType, getConnections, networkConnection } from 'connection'
import { useGetConnection } from 'connection'
import { ErrorCode } from 'connection/utils'
@@ -84,7 +84,7 @@ function didUserReject(connection: Connection, error: any): boolean {
export default function WalletModal({ openSettings }: { openSettings: () => void }) {
const dispatch = useAppDispatch()
const { connector, account, chainId, provider } = useWeb3React()
const [drawerOpen, toggleWalletDrawer] = useWalletDrawer()
const [drawerOpen, toggleWalletDrawer] = useAccountDrawer()
const [connectedWallets, addWalletToConnectedWallets] = useConnectedWallets()
const [lastActiveWalletAddress, setLastActiveWalletAddress] = useState<string | undefined>(account)

View File

@@ -2,19 +2,17 @@ import { Trans } from '@lingui/macro'
import { sendAnalyticsEvent, TraceEvent } from '@uniswap/analytics'
import { BrowserEvent, InterfaceElementName, InterfaceEventName } from '@uniswap/analytics-events'
import { useWeb3React } from '@web3-react/core'
import PortfolioDrawer, { useAccountDrawer } from 'components/AccountDrawer'
import PrefetchBalancesWrapper from 'components/AccountDrawer/PrefetchBalancesWrapper'
import Loader from 'components/Icons/LoadingSpinner'
import { IconWrapper } from 'components/Identicon/StatusIcon'
import WalletDropdown, { useWalletDrawer } from 'components/WalletDropdown'
import PrefetchBalancesWrapper from 'components/WalletDropdown/PrefetchBalancesWrapper'
import { useGetConnection } from 'connection'
import { Portal } from 'nft/components/common/Portal'
import { useIsNftClaimAvailable } from 'nft/hooks/useIsNftClaimAvailable'
import { getIsValidSwapQuote } from 'pages/Swap'
import { darken } from 'polished'
import { useCallback, useMemo } from 'react'
import { AlertTriangle } from 'react-feather'
import { useAppSelector } from 'state/hooks'
import { useDerivedSwapInfo } from 'state/swap/hooks'
import styled from 'styled-components/macro'
import { colors } from 'theme/colors'
import { flexRowNoWrap } from 'theme/styles'
@@ -153,16 +151,11 @@ function Web3StatusInner() {
const { account, connector, chainId, ENSName } = useWeb3React()
const getConnection = useGetConnection()
const connection = getConnection(connector)
const {
trade: { state: tradeState, trade },
inputError: swapInputError,
} = useDerivedSwapInfo()
const validSwapQuote = getIsValidSwapQuote(trade, tradeState, swapInputError)
const [, toggleWalletDrawer] = useWalletDrawer()
const [, toggleAccountDrawer] = useAccountDrawer()
const handleWalletDropdownClick = useCallback(() => {
sendAnalyticsEvent(InterfaceEventName.ACCOUNT_DROPDOWN_BUTTON_CLICKED)
toggleWalletDrawer()
}, [toggleWalletDrawer])
toggleAccountDrawer()
}, [toggleAccountDrawer])
const isClaimAvailable = useIsNftClaimAvailable((state) => state.isClaimAvailable)
const error = useAppSelector((state) => state.connection.errorByConnectionType[getConnection(connector).type])
@@ -223,7 +216,6 @@ function Web3StatusInner() {
<TraceEvent
events={[BrowserEvent.onClick]}
name={InterfaceEventName.CONNECT_WALLET_BUTTON_CLICKED}
properties={{ received_swap_quote: validSwapQuote }}
element={InterfaceElementName.CONNECT_WALLET_BUTTON}
>
<Web3StatusConnectWrapper
@@ -241,13 +233,12 @@ function Web3StatusInner() {
}
}
// eslint-disable-next-line import/no-unused-modules
export default function Web3Status() {
return (
<PrefetchBalancesWrapper>
<Web3StatusInner />
<Portal>
<WalletDropdown />
<PortfolioDrawer />
</Portal>
</PrefetchBalancesWrapper>
)

View File

@@ -16,7 +16,7 @@ import {
SwapWidgetSkeleton,
} from '@uniswap/widgets'
import { useWeb3React } from '@web3-react/core'
import { useToggleWalletDrawer } from 'components/WalletDropdown'
import { useToggleAccountDrawer } from 'components/AccountDrawer'
import { useActiveLocale } from 'hooks/useActiveLocale'
import {
formatPercentInBasisPointsNumber,
@@ -67,7 +67,7 @@ export default function Widget({
const { settings } = useSyncWidgetSettings()
const { transactions } = useSyncWidgetTransactions()
const toggleWalletDrawer = useToggleWalletDrawer()
const toggleWalletDrawer = useToggleAccountDrawer()
const onConnectWalletClick = useCallback(() => {
toggleWalletDrawer()
return false // prevents the in-widget wallet modal from opening

View File

@@ -0,0 +1,111 @@
import userEvent from '@testing-library/user-event'
import { useWeb3React } from '@web3-react/core'
import { useAccountDrawer } from 'components/AccountDrawer'
import { fireEvent, render, screen } from 'test-utils'
import { useFiatOnrampAvailability, useOpenModal } from '../../state/application/hooks'
import SwapBuyFiatButton, { MOONPAY_REGION_AVAILABILITY_ARTICLE } from './SwapBuyFiatButton'
jest.mock('@web3-react/core', () => {
const web3React = jest.requireActual('@web3-react/core')
return {
...web3React,
useWeb3React: jest.fn(),
}
})
jest.mock('../../state/application/hooks')
const mockUseFiatOnrampAvailability = useFiatOnrampAvailability as jest.MockedFunction<typeof useFiatOnrampAvailability>
const mockUseOpenModal = useOpenModal as jest.MockedFunction<typeof useOpenModal>
jest.mock('components/AccountDrawer')
const mockuseAccountDrawer = useAccountDrawer as jest.MockedFunction<typeof useAccountDrawer>
const mockUseFiatOnRampsUnavailable = (shouldCheck: boolean) => {
return {
available: false,
availabilityChecked: shouldCheck,
error: null,
loading: false,
}
}
const mockUseFiatOnRampsAvailable = (shouldCheck: boolean) => {
if (shouldCheck) {
return {
available: true,
availabilityChecked: true,
error: null,
loading: false,
}
}
return {
available: false,
availabilityChecked: false,
error: null,
loading: false,
}
}
describe('SwapBuyFiatButton.tsx', () => {
let toggleWalletDrawer: jest.Mock<any, any>
let useOpenModal: jest.Mock<any, any>
beforeAll(() => {
toggleWalletDrawer = jest.fn()
useOpenModal = jest.fn()
})
beforeEach(() => {
jest.resetAllMocks()
;(useWeb3React as jest.Mock).mockReturnValue({
account: undefined,
isActive: false,
})
})
it('matches base snapshot', () => {
mockUseFiatOnrampAvailability.mockImplementation(mockUseFiatOnRampsUnavailable)
mockuseAccountDrawer.mockImplementation(() => [false, toggleWalletDrawer])
const { asFragment } = render(<SwapBuyFiatButton />)
expect(asFragment()).toMatchSnapshot()
})
it('fiat on ramps available in region, account unconnected', async () => {
mockUseFiatOnrampAvailability.mockImplementation(mockUseFiatOnRampsAvailable)
mockuseAccountDrawer.mockImplementation(() => [false, toggleWalletDrawer])
mockUseOpenModal.mockImplementation(() => useOpenModal)
render(<SwapBuyFiatButton />)
await userEvent.click(screen.getByTestId('buy-fiat-button'))
expect(toggleWalletDrawer).toHaveBeenCalledTimes(1)
expect(screen.queryByTestId('fiat-on-ramp-unavailable-tooltip')).not.toBeInTheDocument()
})
it('fiat on ramps available in region, account connected', async () => {
;(useWeb3React as jest.Mock).mockReturnValue({
account: '0x52270d8234b864dcAC9947f510CE9275A8a116Db',
isActive: true,
})
mockUseFiatOnrampAvailability.mockImplementation(mockUseFiatOnRampsAvailable)
mockuseAccountDrawer.mockImplementation(() => [false, toggleWalletDrawer])
mockUseOpenModal.mockImplementation(() => useOpenModal)
render(<SwapBuyFiatButton />)
expect(screen.getByTestId('buy-fiat-flow-incomplete-indicator')).toBeInTheDocument()
await userEvent.click(screen.getByTestId('buy-fiat-button'))
expect(toggleWalletDrawer).toHaveBeenCalledTimes(0)
expect(useOpenModal).toHaveBeenCalledTimes(1)
expect(screen.queryByTestId('fiat-on-ramp-unavailable-tooltip')).not.toBeInTheDocument()
expect(screen.queryByTestId('buy-fiat-flow-incomplete-indicator')).not.toBeInTheDocument()
})
it('fiat on ramps unavailable in region', async () => {
mockUseFiatOnrampAvailability.mockImplementation(mockUseFiatOnRampsUnavailable)
mockuseAccountDrawer.mockImplementation(() => [false, toggleWalletDrawer])
render(<SwapBuyFiatButton />)
await userEvent.click(screen.getByTestId('buy-fiat-button'))
fireEvent.mouseOver(screen.getByTestId('buy-fiat-button'))
expect(await screen.findByTestId('fiat-on-ramp-unavailable-tooltip')).toBeInTheDocument()
expect(await screen.findByText(/Learn more/i)).toHaveAttribute('href', MOONPAY_REGION_AVAILABILITY_ARTICLE)
expect(await screen.findByTestId('buy-fiat-button')).toBeDisabled()
})
})

View File

@@ -0,0 +1,146 @@
import { Trans } from '@lingui/macro'
import { TraceEvent } from '@uniswap/analytics'
import { BrowserEvent, InterfaceElementName, SharedEventName } from '@uniswap/analytics-events'
import { useWeb3React } from '@web3-react/core'
import { useAccountDrawer } from 'components/AccountDrawer'
import { ButtonText } from 'components/Button'
import { MouseoverTooltipContent } from 'components/Tooltip'
import { useCallback, useEffect, useState } from 'react'
import { useBuyFiatFlowCompleted } from 'state/user/hooks'
import styled from 'styled-components/macro'
import { ExternalLink } from 'theme'
import { useFiatOnrampAvailability, useOpenModal } from '../../state/application/hooks'
import { ApplicationModal } from '../../state/application/reducer'
const Dot = styled.div`
height: 8px;
width: 8px;
background-color: ${({ theme }) => theme.accentActive};
border-radius: 50%;
`
export const MOONPAY_REGION_AVAILABILITY_ARTICLE =
'https://support.uniswap.org/hc/en-us/articles/11306664890381-Why-isn-t-MoonPay-available-in-my-region-'
enum BuyFiatFlowState {
// Default initial state. User is not actively trying to buy fiat.
INACTIVE,
// Buy fiat flow is active and region availability has been checked.
ACTIVE_CHECKING_REGION,
// Buy fiat flow is active, feature is available in user's region & needs wallet connection.
ACTIVE_NEEDS_ACCOUNT,
}
const StyledTextButton = styled(ButtonText)`
color: ${({ theme }) => theme.textSecondary};
gap: 4px;
&:focus {
text-decoration: none;
}
&:active {
text-decoration: none;
}
`
export default function SwapBuyFiatButton() {
const { account } = useWeb3React()
const openFiatOnRampModal = useOpenModal(ApplicationModal.FIAT_ONRAMP)
const [buyFiatFlowCompleted, setBuyFiatFlowCompleted] = useBuyFiatFlowCompleted()
const [checkFiatRegionAvailability, setCheckFiatRegionAvailability] = useState(false)
const {
available: fiatOnrampAvailable,
availabilityChecked: fiatOnrampAvailabilityChecked,
loading: fiatOnrampAvailabilityLoading,
} = useFiatOnrampAvailability(checkFiatRegionAvailability)
const [buyFiatFlowState, setBuyFiatFlowState] = useState(BuyFiatFlowState.INACTIVE)
const [walletDrawerOpen, toggleWalletDrawer] = useAccountDrawer()
/*
* Depending on the current state of the buy fiat flow the user is in (buyFiatFlowState),
* the desired behavior of clicking the 'Buy' button is different.
* 1) Initially upon first click, need to check the availability of the feature in the user's
* region, and continue the flow.
* 2) If the feature is available in the user's region, need to connect a wallet, and continue
* the flow.
* 3) If the feature is available and a wallet account is connected, show fiat on ramp modal.
* 4) If the feature is unavailable, show feature unavailable tooltip.
*/
const handleBuyCrypto = useCallback(() => {
if (!fiatOnrampAvailabilityChecked) {
setCheckFiatRegionAvailability(true)
setBuyFiatFlowState(BuyFiatFlowState.ACTIVE_CHECKING_REGION)
} else if (fiatOnrampAvailable && !account && !walletDrawerOpen) {
toggleWalletDrawer()
setBuyFiatFlowState(BuyFiatFlowState.ACTIVE_NEEDS_ACCOUNT)
} else if (fiatOnrampAvailable && account) {
openFiatOnRampModal()
setBuyFiatFlowCompleted(true)
setBuyFiatFlowState(BuyFiatFlowState.INACTIVE)
} else if (!fiatOnrampAvailable) {
setBuyFiatFlowCompleted(true)
setBuyFiatFlowState(BuyFiatFlowState.INACTIVE)
}
}, [
fiatOnrampAvailabilityChecked,
fiatOnrampAvailable,
account,
walletDrawerOpen,
toggleWalletDrawer,
openFiatOnRampModal,
setBuyFiatFlowCompleted,
])
// Continue buy fiat flow automatically when requisite state changes have occured.
useEffect(() => {
if (
(buyFiatFlowState === BuyFiatFlowState.ACTIVE_CHECKING_REGION && fiatOnrampAvailabilityChecked) ||
(account && buyFiatFlowState === BuyFiatFlowState.ACTIVE_NEEDS_ACCOUNT)
) {
handleBuyCrypto()
}
}, [account, handleBuyCrypto, buyFiatFlowState, fiatOnrampAvailabilityChecked])
const buyCryptoButtonDisabled =
(!fiatOnrampAvailable && fiatOnrampAvailabilityChecked) ||
fiatOnrampAvailabilityLoading ||
// When wallet drawer is open AND user is in the connect wallet step of the buy fiat flow, disable buy fiat button.
(walletDrawerOpen && buyFiatFlowState === BuyFiatFlowState.ACTIVE_NEEDS_ACCOUNT)
const fiatOnRampsUnavailableTooltipDisabled =
!fiatOnrampAvailabilityChecked || (fiatOnrampAvailabilityChecked && fiatOnrampAvailable)
return (
<MouseoverTooltipContent
wrap
content={
<div data-testid="fiat-on-ramp-unavailable-tooltip">
<Trans>Crypto purchases are not available in your region. </Trans>
<TraceEvent
events={[BrowserEvent.onClick]}
name={SharedEventName.ELEMENT_CLICKED}
element={InterfaceElementName.FIAT_ON_RAMP_LEARN_MORE_LINK}
>
<ExternalLink href={MOONPAY_REGION_AVAILABILITY_ARTICLE} style={{ paddingLeft: '4px' }}>
<Trans>Learn more</Trans>
</ExternalLink>
</TraceEvent>
</div>
}
placement="bottom"
disableHover={fiatOnRampsUnavailableTooltipDisabled}
>
<TraceEvent
events={[BrowserEvent.onClick]}
name={SharedEventName.ELEMENT_CLICKED}
element={InterfaceElementName.FIAT_ON_RAMP_BUY_BUTTON}
properties={{ account_connected: !!account }}
>
<StyledTextButton onClick={handleBuyCrypto} disabled={buyCryptoButtonDisabled} data-testid="buy-fiat-button">
<Trans>Buy</Trans>
{!buyFiatFlowCompleted && <Dot data-testid="buy-fiat-flow-incomplete-indicator" />}
</StyledTextButton>
</TraceEvent>
</MouseoverTooltipContent>
)
}

View File

@@ -1,10 +1,12 @@
import { Trans } from '@lingui/macro'
import { Percent } from '@uniswap/sdk-core'
import { useFiatOnRampButtonEnabled } from 'featureFlags/flags/fiatOnRampButton'
import { subhead } from 'nft/css/common.css'
import styled from 'styled-components/macro'
import { ThemedText } from '../../theme'
import { RowBetween, RowFixed } from '../Row'
import SettingsTab from '../Settings'
import SwapBuyFiatButton from './SwapBuyFiatButton'
const StyledSwapHeader = styled.div`
padding: 8px 12px;
@@ -13,14 +15,27 @@ const StyledSwapHeader = styled.div`
color: ${({ theme }) => theme.textSecondary};
`
const TextHeader = styled.div`
color: ${({ theme }) => theme.textPrimary};
margin-right: 8px;
display: flex;
line-height: 20px;
flex-direction: row;
justify-content: center;
align-items: center;
`
export default function SwapHeader({ allowedSlippage }: { allowedSlippage: Percent }) {
const fiatOnRampButtonEnabled = useFiatOnRampButtonEnabled()
return (
<StyledSwapHeader>
<RowBetween>
<RowFixed>
<ThemedText.DeprecatedBlack fontWeight={500} fontSize={16} style={{ marginRight: '8px' }}>
<RowFixed style={{ gap: '8px' }}>
<TextHeader className={subhead}>
<Trans>Swap</Trans>
</ThemedText.DeprecatedBlack>
</TextHeader>
{fiatOnRampButtonEnabled && <SwapBuyFiatButton />}
</RowFixed>
<RowFixed>
<SettingsTab placeholderSlippage={allowedSlippage} />

View File

@@ -0,0 +1,157 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SwapBuyFiatButton.tsx matches base snapshot 1`] = `
<DocumentFragment>
.c1 {
box-sizing: border-box;
margin: 0;
min-width: 0;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
display: inline-block;
text-align: center;
line-height: inherit;
-webkit-text-decoration: none;
text-decoration: none;
font-size: inherit;
padding-left: 16px;
padding-right: 16px;
padding-top: 8px;
padding-bottom: 8px;
color: white;
background-color: primary;
border: 0;
border-radius: 4px;
}
.c2 {
padding: 16px;
width: 100%;
font-weight: 500;
text-align: center;
border-radius: 20px;
outline: none;
border: 1px solid transparent;
color: #0D111C;
-webkit-text-decoration: none;
text-decoration: none;
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-flex-wrap: nowrap;
-ms-flex-wrap: nowrap;
flex-wrap: nowrap;
-webkit-align-items: center;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
cursor: pointer;
position: relative;
z-index: 1;
will-change: transform;
-webkit-transition: -webkit-transform 450ms ease;
-webkit-transition: transform 450ms ease;
transition: transform 450ms ease;
-webkit-transform: perspective(1px) translateZ(0);
-ms-transform: perspective(1px) translateZ(0);
transform: perspective(1px) translateZ(0);
}
.c2:disabled {
opacity: 50%;
cursor: auto;
pointer-events: none;
}
.c2 > * {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.c2 > a {
-webkit-text-decoration: none;
text-decoration: none;
}
.c3 {
padding: 0;
width: -webkit-fit-content;
width: -moz-fit-content;
width: fit-content;
background: none;
-webkit-text-decoration: none;
text-decoration: none;
}
.c3:focus {
-webkit-text-decoration: underline;
text-decoration: underline;
}
.c3:hover {
opacity: 0.9;
}
.c3:active {
-webkit-text-decoration: underline;
text-decoration: underline;
}
.c3:disabled {
opacity: 50%;
cursor: auto;
}
.c0 {
display: inline-block;
height: inherit;
}
.c5 {
height: 8px;
width: 8px;
background-color: #4C82FB;
border-radius: 50%;
}
.c4 {
color: #7780A0;
gap: 4px;
}
.c4:focus {
-webkit-text-decoration: none;
text-decoration: none;
}
.c4:active {
-webkit-text-decoration: none;
text-decoration: none;
}
<div
class="c0"
>
<div>
<button
class="c1 c2 c3 c4"
data-testid="buy-fiat-button"
>
Buy
<div
class="c5"
data-testid="buy-fiat-flow-incomplete-indicator"
/>
</button>
</div>
</div>
</DocumentFragment>
`;

View File

@@ -5,6 +5,7 @@ export enum FeatureFlag {
traceJsonRpc = 'traceJsonRpc',
permit2 = 'permit2',
payWithAnyToken = 'payWithAnyToken',
fiatOnRampButtonOnSwap = 'fiat_on_ramp_button_on_swap_page',
swapWidget = 'swap_widget_replacement_enabled',
statsigDummy = 'web_dummy_gate_amplitude_id',
nftGraphql = 'nft_graphql_migration',

View File

@@ -0,0 +1,9 @@
import { BaseVariant, FeatureFlag, useBaseFlag } from '../index'
function useFiatOnRampButtonFlag(): BaseVariant {
return useBaseFlag(FeatureFlag.fiatOnRampButtonOnSwap)
}
export function useFiatOnRampButtonEnabled(): boolean {
return useFiatOnRampButtonFlag() === BaseVariant.Enabled
}

View File

@@ -39,33 +39,18 @@ gql`
node {
id
name
ownerAddress
image {
url
}
smallImage {
url
}
originalImage {
url
}
tokenId
description
animationUrl
suspiciousFlag
collection {
name
isVerified
image {
url
}
creator {
address
profileImage {
url
}
isVerified
}
nftContracts {
address
standard
@@ -98,11 +83,8 @@ gql`
}
}
rarities {
provider
rank
score
}
metadataUrl
}
cursor
}

View File

@@ -122,7 +122,8 @@ export function useNftBalance(
first?: number,
after?: string,
last?: number,
before?: string
before?: string,
skip = false
) {
const { data, loading, fetchMore } = useNftBalanceQuery({
variables: {
@@ -140,6 +141,7 @@ export function useNftBalance(
last,
before,
},
skip,
})
const hasNext = data?.nftBalances?.pageInfo?.hasNextPage

View File

@@ -18,7 +18,9 @@ describe('fetchTokenList', () => {
fetch.mockOnceIf(url, () => {
throw new Error()
})
await expect(fetchTokenList(url, resolver)).rejects.toThrow(`failed to fetch list: ${url}`)
await expect(fetchTokenList(url, resolver)).rejects.toThrow(
`No valid token list found at any URLs derived from ${url}.`
)
expect(console.debug).toHaveBeenCalled()
expect(resolver).not.toHaveBeenCalled()
})
@@ -33,9 +35,63 @@ describe('fetchTokenList', () => {
expect(resolver).toHaveBeenCalledWith(url)
})
it('throws an error when the ENS resolver throws', async () => {
const url = 'example.eth'
const error = new Error('ENS resolver error')
resolver.mockRejectedValue(error)
await expect(fetchTokenList(url, resolver)).rejects.toThrow(`failed to resolve ENS name: ${url}`)
expect(resolver).toHaveBeenCalledWith(url)
})
it('fetches and validates a list from an ENS address', async () => {
jest.mock('../../utils/contenthashToUri', () =>
jest.fn().mockImplementation(() => 'ipfs://QmPgEqyV3m8SB52BS2j2mJpu9zGprhj2BGCHtRiiw2fdM1')
)
const url = 'example.eth'
const contenthash = '0xe3010170122013e051d1cfff20606de36845d4fe28deb9861a319a5bc8596fa4e610e8803918'
const translatedUri = 'https://cloudflare-ipfs.com/ipfs/QmPgEqyV3m8SB52BS2j2mJpu9zGprhj2BGCHtRiiw2fdM1/'
resolver.mockResolvedValue(contenthash)
fetch.mockOnceIf(translatedUri, () => Promise.resolve(JSON.stringify(defaultTokenList)))
await expect(fetchTokenList(url, resolver)).resolves.toStrictEqual(defaultTokenList)
})
it('throws for an unrecognized list URL protocol', async () => {
const url = 'unknown://example.com/invalid-tokenlist.json'
fetch.mockOnceIf(url, () => Promise.resolve(''))
await expect(fetchTokenList(url, resolver)).rejects.toThrow(`Unrecognized list URL protocol.`)
})
it('logs a debug statement if the response is not successful', async () => {
const url = 'https://example.com/invalid-tokenlist.json'
fetch.mockOnceIf(url, () => Promise.resolve({ status: 404 }))
await expect(fetchTokenList(url, resolver)).rejects.toThrow(
`No valid token list found at any URLs derived from ${url}.`
)
expect(console.debug).toHaveBeenCalled()
expect(resolver).not.toHaveBeenCalled()
})
it('fetches and validates the default token list', async () => {
fetch.mockOnceIf(DEFAULT_TOKEN_LIST, () => Promise.resolve(JSON.stringify(defaultTokenList)))
await expect(fetchTokenList(DEFAULT_TOKEN_LIST, resolver)).resolves.toStrictEqual(defaultTokenList)
expect(resolver).not.toHaveBeenCalled()
})
it('throws for a list with invalid json response', async () => {
const url = 'https://example.com/invalid-tokenlist.json'
fetch.mockOnceIf(url, () => Promise.resolve('invalid json'))
await expect(fetchTokenList(url, resolver)).rejects.toThrow(
`No valid token list found at any URLs derived from ${url}.`
)
expect(console.debug).toHaveBeenCalled()
expect(resolver).not.toHaveBeenCalled()
})
it('uses cached value the second time', async () => {
const url = 'https://example.com/invalid-tokenlist.json'
fetch.mockOnceIf(url, () => Promise.resolve(JSON.stringify(defaultTokenList)))
await expect(fetchTokenList(url, resolver)).resolves.toStrictEqual(defaultTokenList)
await expect(fetchTokenList(url, resolver)).resolves.toStrictEqual(defaultTokenList)
expect(fetch).toHaveBeenCalledTimes(1)
})
})

View File

@@ -8,7 +8,11 @@ export const DEFAULT_TOKEN_LIST = 'https://gateway.ipfs.io/ipns/tokens.uniswap.o
const listCache = new Map<string, TokenList>()
/** Fetches and validates a token list. */
/**
* Fetches and validates a token list.
* For a given token list URL, we try to fetch the list from all the possible HTTP URLs.
* For example, IPFS URLs can be fetched through multiple gateways.
*/
export default async function fetchTokenList(
listUrl: string,
resolveENSContentHash: (ensName: string) => Promise<string>,
@@ -43,31 +47,38 @@ export default async function fetchTokenList(
urls = uriToHttp(listUrl)
}
if (urls.length === 0) {
throw new Error('Unrecognized list URL protocol.')
}
// Try each of the derived URLs until one succeeds.
for (let i = 0; i < urls.length; i++) {
const url = urls[i]
const isLast = i === urls.length - 1
let response
try {
response = await fetch(url, { credentials: 'omit' })
} catch (error) {
const message = `failed to fetch list: ${listUrl}`
console.debug(message, error)
if (isLast) throw new Error(message)
console.debug(`failed to fetch list: ${listUrl} (${url})`, error)
continue
}
if (!response.ok) {
const message = `failed to fetch list: ${listUrl}`
console.debug(message, response.statusText)
if (isLast) throw new Error(message)
console.debug(`failed to fetch list ${listUrl} (${url})`, response.statusText)
continue
}
const json = await response.json()
const list = skipValidation ? json : await validateTokenList(json)
listCache?.set(listUrl, list)
return list
try {
// The content of the result is sometimes invalid even with a 200 status code.
// A response can be invalid if it's not a valid JSON or if it doesn't match the TokenList schema.
const json = await response.json()
const list = skipValidation ? json : await validateTokenList(json)
listCache?.set(listUrl, list)
return list
} catch (error) {
console.debug(`failed to parse and validate list response: ${listUrl} (${url})`, error)
continue
}
}
throw new Error('Unrecognized list URL protocol.')
throw new Error(`No valid token list found at any URLs derived from ${listUrl}.`)
}

View File

@@ -1,6 +1,5 @@
import { i18n } from '@lingui/core'
import { I18nProvider } from '@lingui/react'
import * as Sentry from '@sentry/react'
import { DEFAULT_LOCALE, SupportedLocale } from 'constants/locales'
import {
af,
@@ -83,9 +82,8 @@ export async function dynamicActivate(locale: SupportedLocale) {
const catalog = await import(`locales/${locale}.js`)
// Bundlers will either export it as default or as a named export named default.
i18n.load(locale, catalog.messages || catalog.default.messages)
} catch (error) {
console.error(error)
Sentry.captureException(new Error(`Unable to load locale (${locale})`))
} catch (error: unknown) {
console.error(new Error(`Unable to load locale (${locale}): ${error}`))
}
i18n.activate(locale)
}

View File

@@ -5,6 +5,7 @@ import { sendAnalyticsEvent, TraceEvent } from '@uniswap/analytics'
import { BrowserEvent, InterfaceElementName, NFTEventName } from '@uniswap/analytics-events'
import { Currency, CurrencyAmount, Token, TradeType } from '@uniswap/sdk-core'
import { useWeb3React } from '@web3-react/core'
import { useToggleAccountDrawer } from 'components/AccountDrawer'
import Column from 'components/Column'
import Loader from 'components/Icons/LoadingSpinner'
import CurrencyLogo from 'components/Logo/CurrencyLogo'
@@ -12,7 +13,6 @@ import Row from 'components/Row'
import CurrencySearchModal from 'components/SearchModal/CurrencySearchModal'
import { LoadingBubble } from 'components/Tokens/loading'
import { MouseoverTooltip } from 'components/Tooltip'
import { useToggleWalletDrawer } from 'components/WalletDropdown'
import { SupportedChainId } from 'constants/chains'
import { usePayWithAnyTokenEnabled } from 'featureFlags/flags/payWithAnyToken'
import { useCurrency } from 'hooks/Tokens'
@@ -287,7 +287,7 @@ const PENDING_BAG_STATUSES = [
]
export const BagFooter = ({ setModalIsOpen, eventProperties }: BagFooterProps) => {
const toggleWalletDrawer = useToggleWalletDrawer()
const toggleWalletDrawer = useToggleAccountDrawer()
const theme = useTheme()
const { account, chainId, connector } = useWeb3React()
const connected = Boolean(account && chainId)

View File

@@ -5,11 +5,11 @@ import Bag from './Bag'
jest.mock('@web3-react/core', () => {
const web3React = jest.requireActual('@web3-react/core')
return {
...web3React,
useWeb3React: () => ({
account: '0x52270d8234b864dcAC9947f510CE9275A8a116Db',
isActive: true,
}),
...web3React,
}
})

View File

@@ -2,8 +2,8 @@ import { Trans } from '@lingui/macro'
import { Trace } from '@uniswap/analytics'
import { InterfacePageName } from '@uniswap/analytics-events'
import { useWeb3React } from '@web3-react/core'
import { useToggleAccountDrawer } from 'components/AccountDrawer'
import { ButtonPrimary } from 'components/Button'
import { useToggleWalletDrawer } from 'components/WalletDropdown'
import { XXXL_BAG_WIDTH } from 'nft/components/bag/Bag'
import { ListPage } from 'nft/components/profile/list/ListPage'
import { ProfilePage } from 'nft/components/profile/view/ProfilePage'
@@ -67,7 +67,7 @@ const ProfileContent = () => {
const { account } = useWeb3React()
const accountRef = useRef(account)
const toggleWalletDrawer = useToggleWalletDrawer()
const toggleWalletDrawer = useToggleAccountDrawer()
useEffect(() => {
if (accountRef.current !== account) {

View File

@@ -6,10 +6,10 @@ import { BrowserEvent, InterfaceElementName, InterfaceEventName } from '@uniswap
import { Currency, CurrencyAmount, Percent } from '@uniswap/sdk-core'
import { FeeAmount, NonfungiblePositionManager } from '@uniswap/v3-sdk'
import { useWeb3React } from '@web3-react/core'
import { useToggleAccountDrawer } from 'components/AccountDrawer'
import OwnershipWarning from 'components/addLiquidity/OwnershipWarning'
import { sendEvent } from 'components/analytics'
import UnsupportedCurrencyFooter from 'components/swap/UnsupportedCurrencyFooter'
import { useToggleWalletDrawer } from 'components/WalletDropdown'
import usePrevious from 'hooks/usePrevious'
import { useSingleCallResult } from 'lib/hooks/multicall'
import { useCallback, useEffect, useMemo, useState } from 'react'
@@ -91,7 +91,7 @@ export default function AddLiquidity() {
const { account, chainId, provider } = useWeb3React()
const theme = useTheme()
const toggleWalletDrawer = useToggleWalletDrawer() // toggle wallet when disconnected
const toggleWalletDrawer = useToggleAccountDrawer() // toggle wallet when disconnected
const expertMode = useIsExpertMode()
const addTransaction = useTransactionAdder()
const positionManager = useV3NFTPositionManagerContract()

View File

@@ -5,10 +5,10 @@ import { TraceEvent } from '@uniswap/analytics'
import { BrowserEvent, InterfaceElementName, InterfaceEventName } from '@uniswap/analytics-events'
import { Currency, CurrencyAmount, Percent } from '@uniswap/sdk-core'
import { useWeb3React } from '@web3-react/core'
import { useToggleAccountDrawer } from 'components/AccountDrawer'
import { sendEvent } from 'components/analytics'
import UnsupportedCurrencyFooter from 'components/swap/UnsupportedCurrencyFooter'
import { SwitchLocaleLink } from 'components/SwitchLocaleLink'
import { useToggleWalletDrawer } from 'components/WalletDropdown'
import { useCallback, useState } from 'react'
import { Plus } from 'react-feather'
import { useLocation, useNavigate, useParams } from 'react-router-dom'
@@ -67,7 +67,7 @@ export default function AddLiquidity() {
((currencyA && currencyA.equals(wrappedNativeCurrency)) || (currencyB && currencyB.equals(wrappedNativeCurrency)))
)
const toggleWalletDrawer = useToggleWalletDrawer() // toggle wallet when disconnected
const toggleWalletDrawer = useToggleAccountDrawer() // toggle wallet when disconnected
const expertMode = useIsExpertMode()

View File

@@ -2,13 +2,13 @@ import { Trans } from '@lingui/macro'
import { Trace, TraceEvent } from '@uniswap/analytics'
import { BrowserEvent, InterfaceElementName, InterfaceEventName, InterfacePageName } from '@uniswap/analytics-events'
import { useWeb3React } from '@web3-react/core'
import { useToggleAccountDrawer } from 'components/AccountDrawer'
import { ButtonGray, ButtonPrimary, ButtonText } from 'components/Button'
import { AutoColumn } from 'components/Column'
import { FlyoutAlignment, Menu } from 'components/Menu'
import PositionList from 'components/PositionList'
import { RowBetween, RowFixed } from 'components/Row'
import { SwitchLocaleLink } from 'components/SwitchLocaleLink'
import { useToggleWalletDrawer } from 'components/WalletDropdown'
import { isSupportedChain } from 'constants/chains'
import { useV3Positions } from 'hooks/useV3Positions'
import { useMemo } from 'react'
@@ -196,7 +196,7 @@ function WrongNetworkCard() {
export default function Pool() {
const { account, chainId } = useWeb3React()
const toggleWalletDrawer = useToggleWalletDrawer()
const toggleWalletDrawer = useToggleAccountDrawer()
const theme = useTheme()
const [userHideClosedPositions, setUserHideClosedPositions] = useUserHideClosedPositions()

View File

@@ -6,8 +6,8 @@ import { TraceEvent } from '@uniswap/analytics'
import { BrowserEvent, InterfaceElementName, InterfaceEventName } from '@uniswap/analytics-events'
import { Currency, Percent } from '@uniswap/sdk-core'
import { useWeb3React } from '@web3-react/core'
import { useToggleAccountDrawer } from 'components/AccountDrawer'
import { sendEvent } from 'components/analytics'
import { useToggleWalletDrawer } from 'components/WalletDropdown'
import { useV2LiquidityTokenPermit } from 'hooks/useV2LiquidityTokenPermit'
import { useCallback, useMemo, useState } from 'react'
import { ArrowDown, Plus } from 'react-feather'
@@ -57,7 +57,7 @@ export default function RemoveLiquidity() {
const theme = useTheme()
// toggle wallet when disconnected
const toggleWalletDrawer = useToggleWalletDrawer()
const toggleWalletDrawer = useToggleAccountDrawer()
// burn state
const { independentField, typedValue } = useBurnState()

View File

@@ -12,6 +12,7 @@ import { Trade } from '@uniswap/router-sdk'
import { Currency, CurrencyAmount, Percent, Token, TradeType } from '@uniswap/sdk-core'
import { UNIVERSAL_ROUTER_ADDRESS } from '@uniswap/universal-router-sdk'
import { useWeb3React } from '@web3-react/core'
import { useToggleAccountDrawer } from 'components/AccountDrawer'
import { sendEvent } from 'components/analytics'
import Loader from 'components/Icons/LoadingSpinner'
import { NetworkAlert } from 'components/NetworkAlert/NetworkAlert'
@@ -20,7 +21,6 @@ import SwapDetailsDropdown from 'components/swap/SwapDetailsDropdown'
import UnsupportedCurrencyFooter from 'components/swap/UnsupportedCurrencyFooter'
import TokenSafetyModal from 'components/TokenSafety/TokenSafetyModal'
import { MouseoverTooltip } from 'components/Tooltip'
import { useToggleWalletDrawer } from 'components/WalletDropdown'
import Widget from 'components/Widget'
import { isSupportedChain } from 'constants/chains'
import { useSwapWidgetEnabled } from 'featureFlags/flags/swapWidget'
@@ -127,7 +127,7 @@ const DetailsSwapSection = styled(SwapSection)`
border-top-right-radius: 0;
`
export function getIsValidSwapQuote(
function getIsValidSwapQuote(
trade: InterfaceTrade<Currency, Currency, TradeType> | undefined,
tradeState: TradeState,
swapInputError?: ReactNode
@@ -194,7 +194,7 @@ export default function Swap({ className }: { className?: string }) {
const theme = useTheme()
// toggle wallet when disconnected
const toggleWalletDrawer = useToggleWalletDrawer()
const toggleWalletDrawer = useToggleAccountDrawer()
// for expert mode
const [isExpertMode] = useExpertModeManager()

View File

@@ -1,4 +1,4 @@
import { getVersionUpgrade, minVersionBump, VersionUpgrade } from '@uniswap/token-lists'
import { getVersionUpgrade, VersionUpgrade } from '@uniswap/token-lists'
import { useWeb3React } from '@web3-react/core'
import { DEFAULT_LIST_OF_LISTS, UNSUPPORTED_LIST_URLS } from 'constants/lists'
import useInterval from 'lib/hooks/useInterval'
@@ -10,6 +10,8 @@ import { useAllLists } from 'state/lists/hooks'
import { useFetchListCallback } from '../../hooks/useFetchListCallback'
import useIsWindowVisible from '../../hooks/useIsWindowVisible'
import { acceptListUpdate } from './actions'
import { shouldAcceptVersionUpdate } from './utils'
export default function Updater(): null {
const { provider } = useWeb3React()
const dispatch = useAppDispatch()
@@ -64,14 +66,8 @@ export default function Updater(): null {
throw new Error('unexpected no version bump')
case VersionUpgrade.PATCH:
case VersionUpgrade.MINOR: {
const min = minVersionBump(list.current.tokens, list.pendingUpdate.tokens)
// automatically update minor/patch as long as bump matches the min update
if (bump >= min) {
if (shouldAcceptVersionUpdate(listUrl, list.current, list.pendingUpdate, bump)) {
dispatch(acceptListUpdate(listUrl))
} else {
console.error(
`List at url ${listUrl} could not automatically update because the version bump was only PATCH/MINOR while the update had breaking changes and should have been MAJOR`
)
}
break
}

View File

@@ -0,0 +1,64 @@
import { TokenList, VersionUpgrade } from '@uniswap/token-lists'
import { shouldAcceptVersionUpdate } from './utils'
function buildTokenList(count: number): TokenList {
const tokens = []
for (let i = 0; i < count; i++) {
tokens.push({
name: `Token ${i}`,
address: `0x${i.toString().padStart(40, '0')}`,
symbol: `T${i}`,
decimals: 18,
chainId: 1,
logoURI: `https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x${i
.toString()
.padStart(40, '0')}/logo.png`,
})
}
return {
name: 'Defi',
logoURI:
'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x514910771AF9Ca656af840dff83E8264EcF986CA/logo.png',
keywords: ['defi', 'uniswap'],
timestamp: '2021-03-12T00:00:00.000Z',
version: {
major: 1,
minor: 0,
patch: 0,
},
tokens,
}
}
describe('shouldAcceptMinorVersionUpdate', () => {
it('returns false for patch when tokens have changed', () => {
expect(shouldAcceptVersionUpdate('test_list', buildTokenList(1), buildTokenList(2), VersionUpgrade.PATCH)).toEqual(
false
)
})
it('returns true for patch when tokens are the same', () => {
expect(shouldAcceptVersionUpdate('test_list', buildTokenList(1), buildTokenList(1), VersionUpgrade.PATCH)).toEqual(
true
)
})
it('returns true for minor version bump with tokens added', () => {
expect(shouldAcceptVersionUpdate('test_list', buildTokenList(1), buildTokenList(2), VersionUpgrade.MINOR)).toEqual(
true
)
})
it('returns true for no version bump', () => {
expect(shouldAcceptVersionUpdate('test_list', buildTokenList(1), buildTokenList(2), VersionUpgrade.MINOR)).toEqual(
true
)
})
it('returns false for minor version bump with tokens removed', () => {
expect(shouldAcceptVersionUpdate('test_list', buildTokenList(2), buildTokenList(1), VersionUpgrade.MINOR)).toEqual(
false
)
})
})

19
src/state/lists/utils.ts Normal file
View File

@@ -0,0 +1,19 @@
import { minVersionBump, TokenList, VersionUpgrade } from '@uniswap/token-lists'
export function shouldAcceptVersionUpdate(
listUrl: string,
current: TokenList,
update: TokenList,
targetBump: VersionUpgrade.PATCH | VersionUpgrade.MINOR
): boolean {
const min = minVersionBump(current.tokens, update.tokens)
// Automatically update minor/patch as long as bump matches the min update.
if (targetBump >= min) {
return true
} else {
console.debug(
`List at url ${listUrl} could not automatically update because the version bump was only PATCH/MINOR while the update had breaking changes and should have been MAJOR`
)
return false
}
}

View File

@@ -1,58 +1,20 @@
import { sendAnalyticsEvent } from '@uniswap/analytics'
import { SwapEventName } from '@uniswap/analytics-events'
import { Currency, Percent, TradeType } from '@uniswap/sdk-core'
import { useWeb3React } from '@web3-react/core'
import { DEFAULT_TXN_DISMISS_MS, L2_TXN_DISMISS_MS } from 'constants/misc'
import LibUpdater from 'lib/hooks/transactions/updater'
import { formatPercentInBasisPointsNumber, formatToDecimal, getTokenAddress } from 'lib/utils/analytics'
import { useCallback, useMemo } from 'react'
import { useAppDispatch, useAppSelector } from 'state/hooks'
import { InterfaceTrade } from 'state/routing/types'
import { TransactionType } from 'state/transactions/types'
import { computeRealizedPriceImpact } from 'utils/prices'
import { L2_CHAIN_IDS } from '../../constants/chains'
import { useDerivedSwapInfo } from '../../state/swap/hooks'
import { useAddPopup } from '../application/hooks'
import { checkedTransaction, finalizeTransaction } from './reducer'
import { SerializableTransactionReceipt } from './types'
interface AnalyticsEventProps {
trade: InterfaceTrade<Currency, Currency, TradeType>
hash: string | undefined
allowedSlippage: Percent
succeeded: boolean
}
const formatAnalyticsEventProperties = ({ trade, hash, allowedSlippage, succeeded }: AnalyticsEventProps) => ({
estimated_network_fee_usd: trade.gasUseEstimateUSD ? formatToDecimal(trade.gasUseEstimateUSD, 2) : undefined,
transaction_hash: hash,
token_in_address: getTokenAddress(trade.inputAmount.currency),
token_out_address: getTokenAddress(trade.outputAmount.currency),
token_in_symbol: trade.inputAmount.currency.symbol,
token_out_symbol: trade.outputAmount.currency.symbol,
token_in_amount: formatToDecimal(trade.inputAmount, trade.inputAmount.currency.decimals),
token_out_amount: formatToDecimal(trade.outputAmount, trade.outputAmount.currency.decimals),
price_impact_basis_points: formatPercentInBasisPointsNumber(computeRealizedPriceImpact(trade)),
allowed_slippage_basis_points: formatPercentInBasisPointsNumber(allowedSlippage),
chain_id:
trade.inputAmount.currency.chainId === trade.outputAmount.currency.chainId
? trade.inputAmount.currency.chainId
: undefined,
swap_quote_block_number: trade.blockNumber,
succeeded,
})
export default function Updater() {
const { chainId } = useWeb3React()
const addPopup = useAddPopup()
// speed up popup dismisall time if on L2
const isL2 = Boolean(chainId && L2_CHAIN_IDS.includes(chainId))
const transactions = useAppSelector((state) => state.transactions)
const {
trade: { trade },
allowedSlippage,
} = useDerivedSwapInfo()
const dispatch = useAppDispatch()
const onCheck = useCallback(
@@ -79,19 +41,6 @@ export default function Updater() {
})
)
const tx = transactions[chainId]?.[hash]
if (tx.info.type === TransactionType.SWAP && trade) {
sendAnalyticsEvent(
SwapEventName.SWAP_TRANSACTION_COMPLETED,
formatAnalyticsEventProperties({
trade,
hash,
allowedSlippage,
succeeded: receipt.status === 1,
})
)
}
addPopup(
{
txn: { hash },
@@ -100,7 +49,7 @@ export default function Updater() {
isL2 ? L2_TXN_DISMISS_MS : DEFAULT_TXN_DISMISS_MS
)
},
[addPopup, allowedSlippage, dispatch, isL2, trade, transactions]
[addPopup, dispatch, isL2]
)
const pendingTransactions = useMemo(() => (chainId ? transactions[chainId] ?? {} : {}), [chainId, transactions])

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

@@ -18,6 +18,7 @@ import {
addSerializedToken,
updateHideClosedPositions,
updateHideUniswapWalletBanner,
updateUserBuyFiatFlowCompleted,
updateUserClientSideRouter,
updateUserDeadline,
updateUserExpertMode,
@@ -68,6 +69,18 @@ export function useIsExpertMode(): boolean {
return useAppSelector((state) => state.user.userExpertMode)
}
export function useBuyFiatFlowCompleted(): [boolean | undefined, (buyFiatFlowCompleted: boolean) => void] {
const dispatch = useAppDispatch()
const buyFiatFlowCompleted = useAppSelector((state) => state.user.buyFiatFlowCompleted)
const setBuyFiatFlowCompleted = useCallback(
(buyFiatFlowCompleted: boolean) => {
dispatch(updateUserBuyFiatFlowCompleted(buyFiatFlowCompleted))
},
[dispatch]
)
return [buyFiatFlowCompleted, setBuyFiatFlowCompleted]
}
export function useExpertModeManager(): [boolean, () => void] {
const dispatch = useAppDispatch()
const expertMode = useIsExpertMode()

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

@@ -9,6 +9,8 @@ import { SerializedPair, SerializedToken } from './types'
const currentTimestamp = () => new Date().getTime()
export interface UserState {
buyFiatFlowCompleted: boolean | undefined
selectedWallet?: ConnectionType
// the timestamp of the last updateVersion action
@@ -55,6 +57,7 @@ function pairKey(token0Address: string, token1Address: string) {
}
export const initialState: UserState = {
buyFiatFlowCompleted: undefined,
selectedWallet: undefined,
userExpertMode: false,
userLocale: null,
@@ -75,6 +78,9 @@ const userSlice = createSlice({
name: 'user',
initialState,
reducers: {
updateUserBuyFiatFlowCompleted(state, action) {
state.buyFiatFlowCompleted = action.payload
},
updateSelectedWallet(state, { payload: { wallet } }) {
state.selectedWallet = wallet
},
@@ -163,6 +169,7 @@ const userSlice = createSlice({
export const {
addSerializedPair,
addSerializedToken,
updateUserBuyFiatFlowCompleted,
updateSelectedWallet,
updateHideClosedPositions,
updateUserClientSideRouter,

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

View File

@@ -3566,6 +3566,11 @@
"@testing-library/dom" "^8.5.0"
"@types/react-dom" "^18.0.0"
"@testing-library/user-event@^14.4.3":
version "14.4.3"
resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.4.3.tgz#af975e367743fa91989cd666666aec31a8f50591"
integrity sha512-kCUc5MEwaEMakkO5x7aoD+DLi02ehmEM2QCGWvNqAS1dV/fAvORWEjnjsEIvml59M7Y5kCkWN6fCCyPOe8OL6Q==
"@tootallnate/once@1":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82"
@@ -4562,10 +4567,10 @@
"@typescript-eslint/types" "5.47.0"
eslint-visitor-keys "^3.3.0"
"@uniswap/analytics-events@^2.8.0":
version "2.8.0"
resolved "https://registry.yarnpkg.com/@uniswap/analytics-events/-/analytics-events-2.8.0.tgz#651eb08913b1a47c79814f0536b46cd91a6102d3"
integrity sha512-unaNUxPYGoaPsPS+j6UuQRnxikha6Dr9Knv9jBVY/vIj03f8AOisVM7Zw9493QZP14lq2guARddkN3NzlttuwQ==
"@uniswap/analytics-events@^2.9.0":
version "2.9.0"
resolved "https://registry.yarnpkg.com/@uniswap/analytics-events/-/analytics-events-2.9.0.tgz#80c634bfae850da33b446df0a9fb325869fa8ffa"
integrity sha512-pgrr44L26/0MhRNKC7u8NwjIjVv5tKFqKre2h+TYpzRamZsOD37sR9mnovlF6FA91jNPRMPKK+kHouiimMfmrA==
"@uniswap/analytics@^1.3.1":
version "1.3.1"
@@ -9065,9 +9070,9 @@ domelementtype@1:
integrity sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==
domelementtype@^2.0.1, domelementtype@^2.2.0:
version "2.2.0"
resolved "https://registry.npmjs.org/domelementtype/-/domelementtype-2.2.0.tgz"
integrity sha512-DtBMo82pv1dFtUmHyr48beiuq792Sxohr+8Hm9zoxklYPfa6n0Z3Byjj2IV7bmr2IyqClnqEQhfgHJJ5QF0R5A==
version "2.3.0"
resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d"
integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==
domexception@^2.0.1:
version "2.0.1"
@@ -14135,9 +14140,9 @@ nth-check@^1.0.2:
boolbase "~1.0.0"
nth-check@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.0.0.tgz#1bb4f6dac70072fc313e8c9cd1417b5074c0a125"
integrity sha512-i4sc/Kj8htBrAiH1viZ0TgU8Y5XqCaV/FziYK6TBczxmeKm3AEFWqqF3195yKudrarqy7Zu80Ra5dobFjn9X/Q==
version "2.1.1"
resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.1.1.tgz#c9eab428effce36cd6b92c924bdb000ef1f1ed1d"
integrity sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==
dependencies:
boolbase "^1.0.0"
@@ -14219,9 +14224,9 @@ object-copy@^0.1.0:
kind-of "^3.0.3"
object-inspect@^1.12.2, object-inspect@^1.9.0:
version "1.12.2"
resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.2.tgz#c0641f26394532f28ab8d796ab954e43c009a8ea"
integrity sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==
version "1.12.3"
resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.3.tgz#ba62dffd67ee256c8c086dfae69e016cd1f198b9"
integrity sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==
object-is@^1.0.1:
version "1.1.5"