diff --git a/.gitignore b/.gitignore index 1c63e3e0263..c1e4e271eca 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,6 @@ packages/uniswap/src/i18n/locales/source/*_old.json # Vercel .vercel + +# CodeTours Extension +.tours/* diff --git a/RELEASE b/RELEASE index 3b22156b721..30c39cc5094 100644 --- a/RELEASE +++ b/RELEASE @@ -1,6 +1,6 @@ IPFS hash of the deployment: -- CIDv0: `QmYH4Tb7M6EoFHKRNyCBHHFeNadwnrmibmQmknWHSQxj8R` -- CIDv1: `bafybeietvftf6vmileh7kp3srslms4azyza5cmgu6ttrzikjzz4dkrryxy` +- CIDv0: `QmPq9sW2ih541PM9991Trh3Cocrdstrh63m6UWRbZb6Nqo` +- CIDv1: `bafybeiawfdwfe6ayrmkbbgbjos2noxf2w7ihz4tk7x5tu67ybggnmuvrni` The latest release is always mirrored at [app.uniswap.org](https://app.uniswap.org). @@ -10,15 +10,104 @@ You can also access the Uniswap Interface from an IPFS gateway. Your Uniswap settings are never remembered across different URLs. IPFS gateways: -- https://bafybeietvftf6vmileh7kp3srslms4azyza5cmgu6ttrzikjzz4dkrryxy.ipfs.dweb.link/ -- https://bafybeietvftf6vmileh7kp3srslms4azyza5cmgu6ttrzikjzz4dkrryxy.ipfs.cf-ipfs.com/ -- [ipfs://QmYH4Tb7M6EoFHKRNyCBHHFeNadwnrmibmQmknWHSQxj8R/](ipfs://QmYH4Tb7M6EoFHKRNyCBHHFeNadwnrmibmQmknWHSQxj8R/) +- https://bafybeiawfdwfe6ayrmkbbgbjos2noxf2w7ihz4tk7x5tu67ybggnmuvrni.ipfs.dweb.link/ +- https://bafybeiawfdwfe6ayrmkbbgbjos2noxf2w7ihz4tk7x5tu67ybggnmuvrni.ipfs.cf-ipfs.com/ +- [ipfs://QmPq9sW2ih541PM9991Trh3Cocrdstrh63m6UWRbZb6Nqo/](ipfs://QmPq9sW2ih541PM9991Trh3Cocrdstrh63m6UWRbZb6Nqo/) -## 5.55.0 (2024-10-24) +## 5.56.0 (2024-10-29) ### Features -* **web:** only show bridging card on swap tab- prod (#13338) 95e03e4 +* **web:** add empty states for not connected wallets and wallets with no positions (#12973) 31320f1 +* **web:** add entry points for new lp flow (#13053) 1a7a2d9 +* **web:** add hook parsing util (#13364) ed1eeb6 +* **web:** add mainnet to bridge banner (#13296) 9f2fe44 +* **web:** add new TokenWarningCard to tdp and pdp (#12667) 36688f1 +* **web:** add the hook modal (#13371) d18aae7 +* **web:** add warning icon to search bar (#12768) 830022a +* **web:** adding liquidity create step (#13014) 5ec4b90 +* **web:** adding v4 to the liquidity flow (#12793) 966a85d +* **web:** closed Positions CTA at bottom of positions list (#13308) 2220383 +* **web:** handle insufficient swap approvals (#13201) 60f699a +* **web:** improve fingerprinting for swap errors (#13045) cc066b9 +* **web:** improve remove liquidity modal (#12936) 1ab10ca +* **web:** include poolId on positionInfo object (#13269) 14c26a1 +* **web:** LP creation default one input to native currency (#13167) 9de127c +* **web:** migrate v3 liquidity review modal, saga logic (#13008) 3017b06 +* **web:** mweb layouts for new lp pages (#13317) d0836e0 +* **web:** redesigned pool table tabs (#13291) 918ee0f +* **web:** remove manual wrapping step (#13022) 956377a +* **web:** Remove Vanilla Extract from non-nft code (#12504) 07440e5 +* **web:** support v4 position NFT images (#13349) 88062c8 +* **web:** truncate bridge activity for smaller screens (#13074) 1a79bda +* **web:** UI updates for the pdp page for v4 (#12878) 491748a +* **web:** updates types in Create flow to support native (#13024) 5657b0e +* **web:** use live fee tier data for position creation flow (#12880) 9939608 +* **web:** use tickspacings when fees are selected (#12945) c5908fd +* **web:** v2 create flow setup (#12767) 4a17ba4 +* **web:** v3-v4 migrate calldata query (#12902) c4e9646 +* **web:** v4 create flow creating a pool (#12747) 64dcd7d +* **web:** v4 url redirects (#13237) d91a04c + + +### Bug Fixes + +* **web:** [v4] fix "New" button styling on positions page (#13143) 24e8bb9 +* **web:** [v4] fix reset button (#13160) 8b10c23 +* **web:** [v4] normalize language to collect fees (#13150) 97b4fee +* **web:** [v4] polish (#13204) 9735bed +* **web:** Add 3s delay to portfolio balance refetch (#13367) 4605961 +* **web:** add help center links (#13147) a9239c4 +* **web:** add missing breadcrumb to LP create page (#13306) f0743d2 +* **web:** Align Continue button text (#13023) 9bfcaf5 +* **web:** allow pool creation on testnets (#13009) 05dfe00 +* **web:** bugs in create flow when initializing pool (#13282) c333def +* **web:** check wrapped input approvals for all uniswapx types (#13377) 59a8378 +* **web:** create fee tier alignment and nan (#13157) 701c7a9 +* **web:** default price range fix (#13169) adc95d6 +* **web:** display bridging options in unconnected state (#13048) 0961f12 +* **web:** dont hide position filters (#13194) 2a6b72a +* **web:** fallback to local activity if remote is empty (#13135) b5129d8 +* **web:** fix blocked tokens on TDP (#12742) d364216 +* **web:** fix broken worldchain images (#13028) b9d436d +* **web:** fix crashe in create flow when changing tokens (#13264) 63b9734 +* **web:** Fix explore table only scrolling once (#13110) 73083d7 +* **web:** fix fee modal crash (#13083) 91983fc +* **web:** fix formatting for closed positions (#13172) bfdedd9 +* **web:** fix link to PosDP from migratev3 page (#13311) a8e2050 +* **web:** fix network filter on explore (#12876) e6aaa50 +* **web:** handle account chain id switch (#12994) 45fcd58 +* **web:** handle selecting coin on diff chain (#13149) 49c903d +* **web:** keep old data in positions list while loading new filter results (#13299) 6cc7159 +* **web:** mock pair and mock pool price numerator and denominators are switched (#13279) efe13f3 +* **web:** navbar links for v4 positions pages (#13271) 2b3821e +* **web:** numeric input validation in fee tiers search (#13304) b51bf90 +* **web:** Only poll for bridging status updates if pending txs (#13066) f31d0fc +* **web:** only show bridging card on swap tab (#13333) c1f3eba +* **web:** persist positions filters and remove "closed" from default filter (#13168) f10fc62 +* **web:** prevent swap flow from continuing when approval has not bee… (#13374) 7421208 +* **web:** Redirect to security measures article while clicking button in ResetComplete step (#12606) e917818 +* **web:** remove outputPositionLiquidity from migration request (#13156) 2b4d71c +* **web:** remove second status on pdp (#13210) db5f3e1 +* **web:** remove v2 liquidity (add approve step) (#13314) ce48a33 +* **web:** show liqudity info badge in step and confirmation (#13312) 0c23eff +* **web:** show Not Found on PosDP if it doesn't exist (#13250) 0177752 +* **web:** temp endpoints (#13093) a50b98d +* **web:** unichain modal button widths (#13092) 0eb5abe +* **web:** Unstick continue button from SettingsRecoveryPhrase screen (#12609) 10b17a2 +* **web:** update approved token (#13357) de06e23 +* **web:** update v2 remove on L2 functionality (#13037) 6971b0b +* **web:** use position chain id (#13001) 3872b9a +* **web:** use prod url for positions API (#13351) e25b4ca +* **web:** v4 create flow - reset tokens on chain changed (#13249) ae6ef1a +* **web:** v4 fixes (#13223) 6ad8335 +* **web:** v4 poolsQueryEnabledCheck (#13078) b9f210d +* **web:** various trading api calls fixes (#13102) ecb1c30 + + +### Continuous Integration + +* **web:** update sitemaps afffa8d diff --git a/VERSION b/VERSION index 1e66146efdf..1accfabc6bb 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -web/5.55.0 \ No newline at end of file +web/5.56.0 \ No newline at end of file diff --git a/apps/extension/.eslintrc.js b/apps/extension/.eslintrc.js index bda0b4a2262..2e1293b7c1f 100644 --- a/apps/extension/.eslintrc.js +++ b/apps/extension/.eslintrc.js @@ -12,7 +12,7 @@ module.exports = { 'manifest.json', ], parserOptions: { - project: 'tsconfig.json', + project: 'tsconfig.eslint.json', tsconfigRootDir: __dirname, ecmaFeatures: { jsx: true, diff --git a/apps/extension/package.json b/apps/extension/package.json index 0d305770164..147c594534d 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -4,6 +4,7 @@ "browserslist": "last 2 chrome versions", "dependencies": { "@apollo/client": "3.10.4", + "@datadog/browser-rum": "5.23.3", "@ethersproject/providers": "5.7.2", "@metamask/rpc-errors": "6.2.1", "@reduxjs/toolkit": "1.9.3", @@ -14,10 +15,10 @@ "@tamagui/core": "1.108.4", "@types/uuid": "9.0.1", "@uniswap/analytics-events": "2.38.0", - "@uniswap/uniswapx-sdk": "^2.1.0-beta.14", - "@uniswap/universal-router-sdk": "4.2.0", - "@uniswap/v3-sdk": "3.17.0", - "@uniswap/v4-sdk": "1.10.0", + "@uniswap/uniswapx-sdk": "2.1.0-beta.18", + "@uniswap/universal-router-sdk": "4.5.2", + "@uniswap/v3-sdk": "3.18.1", + "@uniswap/v4-sdk": "1.10.3", "dotenv-webpack": "8.0.1", "ethers": "5.7.2", "eventemitter3": "5.0.1", diff --git a/apps/extension/src/app/OnboardingApp.tsx b/apps/extension/src/app/OnboardingApp.tsx index cf5fd3b117c..398de94fcef 100644 --- a/apps/extension/src/app/OnboardingApp.tsx +++ b/apps/extension/src/app/OnboardingApp.tsx @@ -34,7 +34,7 @@ import { ScanToOnboard } from 'src/app/features/onboarding/scan/ScanToOnboard' import { ScantasticContextProvider } from 'src/app/features/onboarding/scan/ScantasticContextProvider' import { OnboardingRoutes, TopLevelRoutes } from 'src/app/navigation/constants' import { setRouter, setRouterState } from 'src/app/navigation/state' -import { sentryCreateHashRouter } from 'src/app/sentry' +import { SentryAppNameTag, sentryCreateHashRouter } from 'src/app/sentry' import { initExtensionAnalytics } from 'src/app/utils/analytics' import { checksIfSupportsSidePanel } from 'src/app/utils/chrome' import { PrimaryAppInstanceDebuggerLazy } from 'src/store/PrimaryAppInstanceDebuggerLazy' @@ -56,14 +56,6 @@ const unsupportedRoute: RouteObject = { element: , } -const createSteps = { - [CreateOnboardingSteps.Password]: , - [CreateOnboardingSteps.ViewMnemonic]: , - [CreateOnboardingSteps.TestMnemonic]: , - [CreateOnboardingSteps.Naming]: , - [CreateOnboardingSteps.Complete]: , -} - const allRoutes = [ { path: '', @@ -75,7 +67,18 @@ const allRoutes = [ }, { path: OnboardingRoutes.Create, - element: , + element: ( + , + [CreateOnboardingSteps.ViewMnemonic]: , + [CreateOnboardingSteps.TestMnemonic]: , + [CreateOnboardingSteps.Naming]: , + [CreateOnboardingSteps.Complete]: , + }} + /> + ), }, { path: OnboardingRoutes.Claim, @@ -84,7 +87,10 @@ const allRoutes = [ key={OnboardingRoutes.Claim} steps={{ [CreateOnboardingSteps.ClaimUnitag]: , - ...createSteps, + [CreateOnboardingSteps.Password]: , + [CreateOnboardingSteps.ViewMnemonic]: , + [CreateOnboardingSteps.TestMnemonic]: , + [CreateOnboardingSteps.Complete]: , }} /> ), @@ -181,7 +187,7 @@ export default function OnboardingApp(): JSX.Element { return ( - + diff --git a/apps/extension/src/app/PopupApp.tsx b/apps/extension/src/app/PopupApp.tsx index c592bdd2050..dcccdb561d1 100644 --- a/apps/extension/src/app/PopupApp.tsx +++ b/apps/extension/src/app/PopupApp.tsx @@ -13,7 +13,6 @@ import { TraceUserProperties } from 'src/app/components/Trace/TraceUserPropertie import { DappContextProvider } from 'src/app/features/dapp/DappContext' import { SentryAppNameTag, initializeSentry, sentryCreateHashRouter } from 'src/app/sentry' import { initExtensionAnalytics } from 'src/app/utils/analytics' -import { getLocalUserId } from 'src/app/utils/storage' import { getReduxPersistor, getReduxStore } from 'src/store/store' import { Button, Flex, Image, Text } from 'ui/src' import { CHROME_LOGO, UNISWAP_LOGO } from 'ui/src/assets' @@ -25,18 +24,19 @@ import { ElementName } from 'uniswap/src/features/telemetry/constants' import { UnitagUpdaterContextProvider } from 'uniswap/src/features/unitags/context' import i18n from 'uniswap/src/i18n/i18n' import { ExtensionScreens } from 'uniswap/src/types/screens/extension' +import { getUniqueId } from 'utilities/src/device/getUniqueId' import { logger } from 'utilities/src/logger/logger' import { ErrorBoundary } from 'wallet/src/components/ErrorBoundary/ErrorBoundary' import { useTestnetModeForLoggingAndAnalytics } from 'wallet/src/features/testnetMode/hooks' import { SharedWalletProvider } from 'wallet/src/providers/SharedWalletProvider' -getLocalUserId() +getUniqueId() .then((userId) => { initializeSentry(SentryAppNameTag.Popup, userId) }) .catch((error) => { logger.error(error, { - tags: { file: 'PopupApp.tsx', function: 'getLocalUserId' }, + tags: { file: 'PopupApp.tsx', function: 'getUniqueId' }, }) }) @@ -127,7 +127,7 @@ export default function PopupApp(): JSX.Element { return ( - + diff --git a/apps/extension/src/app/SidebarApp.tsx b/apps/extension/src/app/SidebarApp.tsx index 9d975c8dfb6..8540e616c78 100644 --- a/apps/extension/src/app/SidebarApp.tsx +++ b/apps/extension/src/app/SidebarApp.tsx @@ -31,7 +31,6 @@ import { MainContent, WebNavigation } from 'src/app/navigation/navigation' import { setRouter, setRouterState } from 'src/app/navigation/state' import { SentryAppNameTag, initializeSentry, sentryCreateHashRouter } from 'src/app/sentry' import { initExtensionAnalytics } from 'src/app/utils/analytics' -import { getLocalUserId } from 'src/app/utils/storage' import { DappBackgroundPortChannel, backgroundToSidePanelMessageChannel, @@ -47,6 +46,7 @@ import { ExtensionEventName } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import { UnitagUpdaterContextProvider, useUnitagUpdater } from 'uniswap/src/features/unitags/context' import i18n from 'uniswap/src/i18n/i18n' +import { getUniqueId } from 'utilities/src/device/getUniqueId' import { isDevEnv } from 'utilities/src/environment/env' import { logger } from 'utilities/src/logger/logger' import { ONE_SECOND_MS } from 'utilities/src/time/time' @@ -55,13 +55,13 @@ import { ErrorBoundary } from 'wallet/src/components/ErrorBoundary/ErrorBoundary import { useTestnetModeForLoggingAndAnalytics } from 'wallet/src/features/testnetMode/hooks' import { SharedWalletProvider } from 'wallet/src/providers/SharedWalletProvider' -getLocalUserId() +getUniqueId() .then((userId) => { initializeSentry(SentryAppNameTag.Sidebar, userId) }) .catch((error) => { logger.error(error, { - tags: { file: 'SidebarApp.tsx', function: 'getLocalUserId' }, + tags: { file: 'SidebarApp.tsx', function: 'getUniqueId' }, }) }) @@ -257,7 +257,7 @@ export default function SidebarApp(): JSX.Element { return ( - + diff --git a/apps/extension/src/app/StatsigProvider.tsx b/apps/extension/src/app/StatsigProvider.tsx index c0102bdb782..ce4166a40a7 100644 --- a/apps/extension/src/app/StatsigProvider.tsx +++ b/apps/extension/src/app/StatsigProvider.tsx @@ -1,14 +1,16 @@ -import { getLocalUserId } from 'src/app/utils/storage' +import { useEffect, useState } from 'react' +import { initializeDatadog } from 'src/app/datadog' import { getStatsigEnvironmentTier } from 'src/app/version' import Statsig from 'statsig-js' // Use JS package for browser import { uniswapUrls } from 'uniswap/src/constants/urls' import { DUMMY_STATSIG_SDK_KEY, StatsigCustomAppValue } from 'uniswap/src/features/gating/constants' import { StatsigOptions, StatsigProvider, StatsigUser } from 'uniswap/src/features/gating/sdk/statsig' +import { getUniqueId } from 'utilities/src/device/getUniqueId' import { useAsyncData } from 'utilities/src/react/hooks' async function getStatsigUser(): Promise { return { - userID: await getLocalUserId(), + userID: await getUniqueId(), appVersion: process.env.VERSION, custom: { app: StatsigCustomAppValue.Extension, @@ -16,16 +18,28 @@ async function getStatsigUser(): Promise { } } -export function ExtensionStatsigProvider({ children }: { children: React.ReactNode }): JSX.Element { - const { data: user } = useAsyncData(getStatsigUser) - - const nonNullUser: StatsigUser = user ?? { +export function ExtensionStatsigProvider({ + children, + appName, +}: { + children: React.ReactNode + appName: string +}): JSX.Element { + const { data: storedUser } = useAsyncData(getStatsigUser) + const [user, setUser] = useState({ userID: undefined, custom: { app: StatsigCustomAppValue.Extension, }, appVersion: process.env.VERSION, - } + }) + const [initFinished, setInitFinished] = useState(false) + + useEffect(() => { + if (storedUser && initFinished) { + setUser(storedUser) + } + }, [storedUser, initFinished]) const options: StatsigOptions = { environment: { @@ -34,10 +48,14 @@ export function ExtensionStatsigProvider({ children }: { children: React.ReactNo api: uniswapUrls.statsigProxyUrl, disableAutoMetricsLogging: true, disableErrorLogging: true, + initCompletionCallback: () => { + setInitFinished(true) + initializeDatadog(appName).catch(() => undefined) + }, } return ( - + {children} ) diff --git a/apps/extension/src/app/UnitagClaimApp.tsx b/apps/extension/src/app/UnitagClaimApp.tsx index 3434a5788d8..d5a35563bfc 100644 --- a/apps/extension/src/app/UnitagClaimApp.tsx +++ b/apps/extension/src/app/UnitagClaimApp.tsx @@ -1,57 +1,70 @@ import '@tamagui/core/reset.css' import 'src/app/Global.css' -import { useEffect } from 'react' +import { PropsWithChildren, useEffect } from 'react' import { I18nextProvider } from 'react-i18next' -import { Outlet, RouterProvider } from 'react-router-dom' +import { Outlet, RouterProvider, useSearchParams } from 'react-router-dom' import { PersistGate } from 'redux-persist/integration/react' import { ExtensionStatsigProvider } from 'src/app/StatsigProvider' import { GraphqlProvider } from 'src/app/apollo' import { ErrorElement } from 'src/app/components/ErrorElement' import { TraceUserProperties } from 'src/app/components/Trace/TraceUserProperties' -import { ClaimUnitagSteps, OnboardingStepsProvider } from 'src/app/features/onboarding/OnboardingSteps' +import { + ClaimUnitagSteps, + OnboardingStepsProvider, + useOnboardingSteps, +} from 'src/app/features/onboarding/OnboardingSteps' import { EditUnitagProfileScreen } from 'src/app/features/unitags/EditUnitagProfileScreen' import { UnitagChooseProfilePicScreen } from 'src/app/features/unitags/UnitagChooseProfilePicScreen' +import { UnitagClaimBackground } from 'src/app/features/unitags/UnitagClaimBackground' import { UnitagClaimContextProvider } from 'src/app/features/unitags/UnitagClaimContext' import { UnitagConfirmationScreen } from 'src/app/features/unitags/UnitagConfirmationScreen' import { UnitagCreateUsernameScreen } from 'src/app/features/unitags/UnitagCreateUsernameScreen' import { UnitagIntroScreen } from 'src/app/features/unitags/UnitagIntroScreen' -import { OnboardingRoutes } from 'src/app/navigation/constants' +import { UnitagClaimRoutes } from 'src/app/navigation/constants' import { setRouter, setRouterState } from 'src/app/navigation/state' import { SentryAppNameTag, initializeSentry, sentryCreateHashRouter } from 'src/app/sentry' import { initExtensionAnalytics } from 'src/app/utils/analytics' -import { getLocalUserId } from 'src/app/utils/storage' import { getReduxPersistor, getReduxStore } from 'src/store/store' import { Flex } from 'ui/src' import { LocalizationContextProvider } from 'uniswap/src/features/language/LocalizationContext' import Trace from 'uniswap/src/features/telemetry/Trace' import { UnitagUpdaterContextProvider } from 'uniswap/src/features/unitags/context' import i18n from 'uniswap/src/i18n/i18n' +import { getUniqueId } from 'utilities/src/device/getUniqueId' import { logger } from 'utilities/src/logger/logger' +import { usePrevious } from 'utilities/src/react/hooks' import { ErrorBoundary } from 'wallet/src/components/ErrorBoundary/ErrorBoundary' import { useTestnetModeForLoggingAndAnalytics } from 'wallet/src/features/testnetMode/hooks' +import { useAccountAddressFromUrlWithThrow } from 'wallet/src/features/wallet/hooks' import { SharedWalletProvider } from 'wallet/src/providers/SharedWalletProvider' -getLocalUserId() +getUniqueId() .then((userId) => { initializeSentry(SentryAppNameTag.UnitagClaim, userId) }) .catch((error) => { logger.error(error, { - tags: { file: 'UnitagClaimApp.tsx', function: 'getLocalUserId' }, + tags: { file: 'UnitagClaimApp.tsx', function: 'getUniqueId' }, }) }) const router = sentryCreateHashRouter([ { path: '', - element: , - errorElement: , - }, - { - path: OnboardingRoutes.EditProfile, - element: , - errorElement: , + element: , + children: [ + { + path: UnitagClaimRoutes.ClaimIntro, + element: , + errorElement: , + }, + { + path: UnitagClaimRoutes.EditProfile, + element: , + errorElement: , + }, + ], }, ]) @@ -66,10 +79,36 @@ router.subscribe((state) => { setRouter(router) -function UnitagClaimAppInner(): JSX.Element { +function UnitagAppInner(): JSX.Element { + const [searchParams, setSearchParams] = useSearchParams() + + const address = useAccountAddressFromUrlWithThrow() + const prevAddress = usePrevious(address) + + // Ensures that address in url search params is consistent with hook + useEffect(() => { + if (searchParams.get('address') !== address) { + setSearchParams({ address }) + } + }, [searchParams, address, setSearchParams]) + + useEffect(() => { + if (prevAddress && address !== prevAddress) { + // needed to reload on address param change for hash router + router + .navigate(0) + .catch((e) => logger.error(e, { tags: { file: 'UnitagClaimApp.tsx', function: 'UnitagClaimAppInner' } })) + } + }, [address, prevAddress]) + useTestnetModeForLoggingAndAnalytics() + + return +} + +function UnitagClaimFlow(): JSX.Element { return ( - + , [ClaimUnitagSteps.EditProfile]: , }} - ContainerComponent={UnitagClaimContextProvider} + ContainerComponent={UnitagClaimAppWrapper} /> ) } -function EditProfileAppInner(): JSX.Element { +function UnitagClaimAppWrapper({ children }: PropsWithChildren): JSX.Element { + const { step } = useOnboardingSteps() + const blurAllBackground = step !== ClaimUnitagSteps.Intro + + return ( + + {children} + + ) +} + +function UnitagEditProfileFlow(): JSX.Element { return ( - + , + [ClaimUnitagSteps.EditProfile]: , }} - ContainerComponent={UnitagClaimContextProvider} + ContainerComponent={UnitagClaimAppWrapper} /> @@ -111,7 +161,7 @@ export default function UnitagClaimApp(): JSX.Element { return ( - + diff --git a/apps/extension/src/app/components/Trace/TraceUserProperties.tsx b/apps/extension/src/app/components/Trace/TraceUserProperties.tsx index f817ec2fee5..99a70235839 100644 --- a/apps/extension/src/app/components/Trace/TraceUserProperties.tsx +++ b/apps/extension/src/app/components/Trace/TraceUserProperties.tsx @@ -2,7 +2,11 @@ import { useEffect } from 'react' import { useColorScheme } from 'react-native' import { useAppFiatCurrencyInfo } from 'uniswap/src/features/fiatCurrency/hooks' import { useCurrentLanguage } from 'uniswap/src/features/language/hooks' -import { useHideSmallBalancesSetting, useHideSpamTokensSetting } from 'uniswap/src/features/settings/hooks' +import { + useEnabledChains, + useHideSmallBalancesSetting, + useHideSpamTokensSetting, +} from 'uniswap/src/features/settings/hooks' import { ExtensionUserPropertyName, setUserProperty } from 'uniswap/src/features/telemetry/user' // eslint-disable-next-line no-restricted-imports import { analytics } from 'utilities/src/telemetry/analytics/analytics' @@ -19,6 +23,7 @@ export function TraceUserProperties(): null { const hideSpamTokens = useHideSpamTokensSetting() const currentLanguage = useCurrentLanguage() const appFiatCurrencyInfo = useAppFiatCurrencyInfo() + const { isTestnetModeEnabled } = useEnabledChains() useGatingUserPropertyUsernames() @@ -63,5 +68,9 @@ export function TraceUserProperties(): null { setUserProperty(ExtensionUserPropertyName.Currency, appFiatCurrencyInfo.code) }, [appFiatCurrencyInfo]) + useEffect(() => { + setUserProperty(ExtensionUserPropertyName.TestnetModeEnabled, isTestnetModeEnabled) + }, [isTestnetModeEnabled]) + return null } diff --git a/apps/extension/src/app/datadog.ts b/apps/extension/src/app/datadog.ts new file mode 100644 index 00000000000..6abe6f281ec --- /dev/null +++ b/apps/extension/src/app/datadog.ts @@ -0,0 +1,74 @@ +import { datadogRum } from '@datadog/browser-rum' +import { getDatadogEnvironment } from 'src/app/version' +import { config } from 'uniswap/src/config' +import { Experiments } from 'uniswap/src/features/gating/experiments' +import { FeatureFlags, WALLET_FEATURE_FLAG_NAMES, getFeatureFlagName } from 'uniswap/src/features/gating/flags' +import { Statsig } from 'uniswap/src/features/gating/sdk/statsig' +import { getUniqueId } from 'utilities/src/device/getUniqueId' +import { logger } from 'utilities/src/logger/logger' + +export async function initializeDatadog(appName: string): Promise { + const datadogEnabled = Statsig.checkGate(getFeatureFlagName(FeatureFlags.Datadog)) + logger.setWalletDatadogEnabled(datadogEnabled) + + if (__DEV__ || !datadogEnabled) { + return + } + + datadogRum.init({ + applicationId: config.datadogProjectId, + clientToken: config.datadogClientToken, + service: `extension-${getDatadogEnvironment()}`, + env: getDatadogEnvironment(), + version: process.env.VERSION, + sessionSampleRate: 100, + sessionReplaySampleRate: 0, + trackResources: true, + trackLongTasks: true, + trackUserInteractions: true, + enablePrivacyForActionName: true, + beforeSend: (event) => { + // otherwise DataDog will ignore error events + event.view.url = event.view.url.replace(/^chrome-extension:\/\/[a-z]{32}\//i, '') + if (event.error && event.type === 'error') { + Object.defineProperty(event.error, 'stack', { + value: event.error.stack?.replace(/chrome-extension:\/\/[a-z]{32}/gi, ''), + writable: false, + configurable: true, + }) + } + return true + }, + }) + + try { + const userId = await getUniqueId() + datadogRum.setUser({ + id: userId, + }) + } catch (e) { + logger.error(e, { + tags: { file: 'datadog.ts', function: 'initializeDatadog' }, + }) + } + + datadogRum.setGlobalContextProperty('app', appName) + + for (const [_, flagKey] of WALLET_FEATURE_FLAG_NAMES.entries()) { + datadogRum.addFeatureFlagEvaluation( + // Datadog has a limited set of accepted symbols in feature flags + // https://docs.datadoghq.com/real_user_monitoring/guide/setup-feature-flag-data-collection/?tab=reactnative#feature-flag-naming + flagKey.replaceAll('-', '_'), + Statsig.checkGateWithExposureLoggingDisabled(flagKey), + ) + } + + for (const experiment of Object.values(Experiments)) { + datadogRum.addFeatureFlagEvaluation( + // Datadog has a limited set of accepted symbols in feature flags + // https://docs.datadoghq.com/real_user_monitoring/guide/setup-feature-flag-data-collection/?tab=reactnative#feature-flag-naming + `experiment_${experiment.replaceAll('-', '_')}`, + Statsig.getExperimentWithExposureLoggingDisabled(experiment).getGroupName(), + ) + } +} diff --git a/apps/extension/src/app/features/accounts/AccountItem.tsx b/apps/extension/src/app/features/accounts/AccountItem.tsx index 981f0bfc875..7535624a80e 100644 --- a/apps/extension/src/app/features/accounts/AccountItem.tsx +++ b/apps/extension/src/app/features/accounts/AccountItem.tsx @@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next' import { useDispatch } from 'react-redux' import { EditLabelModal } from 'src/app/features/accounts/EditLabelModal' import { removeAllDappConnectionsForAccount } from 'src/app/features/dapp/actions' -import { ContextMenu, Flex, MenuContentItem, Text, TouchableArea } from 'ui/src' +import { Flex, Text, TouchableArea } from 'ui/src' import { CopySheets, Edit, Ellipsis, TrashFilled } from 'ui/src/components/icons' import { iconSizes } from 'ui/src/theme' import { WarningModal } from 'uniswap/src/components/modals/WarningModal/WarningModal' @@ -17,6 +17,8 @@ import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import { setClipboard } from 'uniswap/src/utils/clipboard' import { NumberType } from 'utilities/src/format/types' import { AddressDisplay } from 'wallet/src/components/accounts/AddressDisplay' +import { ContextMenu } from 'wallet/src/components/menu/ContextMenu' +import { MenuContentItem } from 'wallet/src/components/menu/types' import { EditAccountAction, editAccountActions } from 'wallet/src/features/wallet/accounts/editAccountSaga' import { useActiveAccountWithThrow, useDisplayName, useSignerAccounts } from 'wallet/src/features/wallet/hooks' import { DisplayNameType } from 'wallet/src/features/wallet/types' diff --git a/apps/extension/src/app/features/accounts/AccountSwitcherScreen.tsx b/apps/extension/src/app/features/accounts/AccountSwitcherScreen.tsx index 1cc9ecb1667..603dda4e9da 100644 --- a/apps/extension/src/app/features/accounts/AccountSwitcherScreen.tsx +++ b/apps/extension/src/app/features/accounts/AccountSwitcherScreen.tsx @@ -11,10 +11,10 @@ import { updateDappConnectedAddressFromExtension } from 'src/app/features/dapp/a import { useDappConnectedAccounts } from 'src/app/features/dapp/hooks' import { isConnectedAccount } from 'src/app/features/dapp/utils' import { PopupName, openPopup } from 'src/app/features/popups/slice' -import { AppRoutes, OnboardingRoutes, RemoveRecoveryPhraseRoutes, SettingsRoutes } from 'src/app/navigation/constants' +import { AppRoutes, RemoveRecoveryPhraseRoutes, SettingsRoutes, UnitagClaimRoutes } from 'src/app/navigation/constants' import { navigate } from 'src/app/navigation/state' import { focusOrCreateUnitagTab } from 'src/app/navigation/utils' -import { Button, Flex, MenuContent, MenuContentItem, Popover, ScrollView, Text, useSporeColors } from 'ui/src' +import { Button, Flex, Popover, ScrollView, Text, useSporeColors } from 'ui/src' import { WalletFilled, X } from 'ui/src/components/icons' import { spacing } from 'ui/src/theme' import { WarningModal } from 'uniswap/src/components/modals/WarningModal/WarningModal' @@ -31,11 +31,18 @@ import { logger } from 'utilities/src/logger/logger' import { sleep } from 'utilities/src/time/timing' import { AddressDisplay } from 'wallet/src/components/accounts/AddressDisplay' import { PlusCircle } from 'wallet/src/components/icons/PlusCircle' +import { MenuContent } from 'wallet/src/components/menu/MenuContent' +import { MenuContentItem } from 'wallet/src/components/menu/types' import { useAccountList } from 'wallet/src/features/accounts/hooks' import { createOnboardingAccount } from 'wallet/src/features/onboarding/createOnboardingAccount' import { BackupType, SignerMnemonicAccount } from 'wallet/src/features/wallet/accounts/types' import { createAccountsActions } from 'wallet/src/features/wallet/create/createAccountsSaga' -import { useActiveAccountWithThrow, useDisplayName, useSignerAccounts } from 'wallet/src/features/wallet/hooks' +import { + useActiveAccountAddressWithThrow, + useActiveAccountWithThrow, + useDisplayName, + useSignerAccounts, +} from 'wallet/src/features/wallet/hooks' import { selectSortedSignerMnemonicAccounts } from 'wallet/src/features/wallet/selectors' import { setAccountAsActive } from 'wallet/src/features/wallet/slice' import { DisplayNameType } from 'wallet/src/features/wallet/types' @@ -284,11 +291,12 @@ export function AccountSwitcherScreen(): JSX.Element { const UnitagActionButton = (): JSX.Element => { const { t } = useTranslation() + const address = useActiveAccountAddressWithThrow() const isClaimUnitagEnabled = useFeatureFlag(FeatureFlags.ExtensionClaimUnitag) const onPressEditProfile = useCallback(async () => { - await focusOrCreateUnitagTab(OnboardingRoutes.EditProfile) - }, []) + await focusOrCreateUnitagTab(address, UnitagClaimRoutes.EditProfile) + }, [address]) if (isClaimUnitagEnabled) { return ( diff --git a/apps/extension/src/app/features/accounts/EditLabelModal.tsx b/apps/extension/src/app/features/accounts/EditLabelModal.tsx index 97b0471125f..8b0cf5ab8c9 100644 --- a/apps/extension/src/app/features/accounts/EditLabelModal.tsx +++ b/apps/extension/src/app/features/accounts/EditLabelModal.tsx @@ -1,13 +1,22 @@ import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { useDispatch } from 'react-redux' +import { UnitagClaimRoutes } from 'src/app/navigation/constants' +import { focusOrCreateUnitagTab } from 'src/app/navigation/utils' import { Button, Flex, Text } from 'ui/src' +import { Person } from 'ui/src/components/icons' import { iconSizes } from 'ui/src/theme' import { TextInput } from 'uniswap/src/components/input/TextInput' import { Modal } from 'uniswap/src/components/modals/Modal' +import { FeatureFlags } from 'uniswap/src/features/gating/flags' +import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { OnboardingCardLoggingName } from 'uniswap/src/features/telemetry/types' import { shortenAddress } from 'utilities/src/addresses' import { AccountIcon } from 'wallet/src/components/accounts/AccountIcon' +import { CardType, IntroCard, IntroCardGraphicType } from 'wallet/src/components/introCards/IntroCard' +import { UNITAG_SUFFIX_NO_LEADING_DOT } from 'wallet/src/features/unitags/constants' +import { useCanActiveAddressClaimUnitag } from 'wallet/src/features/unitags/hooks' import { EditAccountAction, editAccountActions } from 'wallet/src/features/wallet/accounts/editAccountSaga' import { useDisplayName } from 'wallet/src/features/wallet/hooks' import { DisplayNameType } from 'wallet/src/features/wallet/types' @@ -28,6 +37,9 @@ export function EditLabelModal({ isOpen, address, onClose }: EditLabelModalProps const [inputText, setInputText] = useState(defaultText) const [isfocused, setIsFocused] = useState(false) + const { canClaimUnitag } = useCanActiveAddressClaimUnitag() + const unitagsClaimEnabled = useFeatureFlag(FeatureFlags.ExtensionClaimUnitag) + const onConfirm = useCallback(async () => { await dispatch( editAccountActions.trigger({ @@ -39,8 +51,34 @@ export function EditLabelModal({ isOpen, address, onClose }: EditLabelModalProps onClose() }, [address, dispatch, inputText, onClose]) + const navigateToUnitagClaim = useCallback(async () => { + await focusOrCreateUnitagTab(address, UnitagClaimRoutes.ClaimIntro) + }, [address]) + + const unitagClaimCard = ( + + ) + return ( - + diff --git a/apps/extension/src/app/features/accounts/__snapshots__/AccountSwitcherScreen.test.tsx.snap b/apps/extension/src/app/features/accounts/__snapshots__/AccountSwitcherScreen.test.tsx.snap index 820c1a13e85..c8ad496c2db 100644 --- a/apps/extension/src/app/features/accounts/__snapshots__/AccountSwitcherScreen.test.tsx.snap +++ b/apps/extension/src/app/features/accounts/__snapshots__/AccountSwitcherScreen.test.tsx.snap @@ -107,7 +107,13 @@ exports[`AccountSwitcherScreen renders correctly 1`] = ` > + @@ -127,9 +133,9 @@ exports[`AccountSwitcherScreen renders correctly 1`] = ` - Tamara Brekke + Colin Schowalter Jr. @@ -144,7 +150,7 @@ exports[`AccountSwitcherScreen renders correctly 1`] = ` class="font_body _display-inline _boxSizing-border-box _whiteSpace-pre-wrap _mt-0px _mr-0px _mb-0px _ml-0px _color-843135005 _fontFamily-299667014 _wordWrap-break-word _fontSize-229441158 _lineHeight-222976511 _fontWeight-233016202" data-disable-theme="true" > - 0x​e0c6...ea11 + 0x​9eb6...a2ca + @@ -353,9 +365,9 @@ exports[`AccountSwitcherScreen renders correctly 1`] = ` - Tamara Brekke + Colin Schowalter Jr. @@ -370,7 +382,7 @@ exports[`AccountSwitcherScreen renders correctly 1`] = ` class="font_body _display-inline _boxSizing-border-box _whiteSpace-pre-wrap _mt-0px _mr-0px _mb-0px _ml-0px _color-843135005 _fontFamily-299667014 _wordWrap-break-word _fontSize-229441158 _lineHeight-222976511 _fontWeight-233016202" data-disable-theme="true" > - 0x​e0c6...ea11 + 0x​9eb6...a2ca { if (onConfirm) { diff --git a/apps/extension/src/app/features/dappRequests/DappRequestQueue.tsx b/apps/extension/src/app/features/dappRequests/DappRequestQueue.tsx index c04e66e26da..6947ca04591 100644 --- a/apps/extension/src/app/features/dappRequests/DappRequestQueue.tsx +++ b/apps/extension/src/app/features/dappRequests/DappRequestQueue.tsx @@ -2,6 +2,7 @@ import { memo } from 'react' import { Trans, useTranslation } from 'react-i18next' import { useDispatch, useSelector } from 'react-redux' import { AnimatedPane, DappRequestContent } from 'src/app/features/dappRequests/DappRequestContent' +import { DappRequestCards } from 'src/app/features/dappRequests/DappRequestQueueCards' import { DappRequestQueueProvider, useDappRequestQueueContext, @@ -178,6 +179,7 @@ function DappRequestQueueContent(): JSX.Element { )} + ) } diff --git a/apps/extension/src/app/features/dappRequests/DappRequestQueueCards.tsx b/apps/extension/src/app/features/dappRequests/DappRequestQueueCards.tsx new file mode 100644 index 00000000000..b30bf41f83a --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/DappRequestQueueCards.tsx @@ -0,0 +1,53 @@ +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch } from 'react-redux' +import { useDappRequestQueueContext } from 'src/app/features/dappRequests/DappRequestQueueContext' +import { useShouldShowBridgingRequestCard } from 'src/app/features/dappRequests/hooks' +import { BRIDGING_BANNER } from 'ui/src/assets' +import { DappRequestCardLoggingName } from 'uniswap/src/features/telemetry/types' +import { CurrencyField } from 'uniswap/src/types/currency' +import { CardType, IntroCard, IntroCardGraphicType, IntroCardProps } from 'wallet/src/components/introCards/IntroCard' +import { useWalletNavigation } from 'wallet/src/contexts/WalletNavigationContext' +import { setHasViewedDappRequestBridgingBanner } from 'wallet/src/features/behaviorHistory/slice' + +export function DappRequestCards(): JSX.Element | null { + const { t } = useTranslation() + const dispatch = useDispatch() + const { request, dappUrl, onCancel, totalRequestCount } = useDappRequestQueueContext() + const { navigateToSwapFlow } = useWalletNavigation() + + const { numBridgingChains, shouldShowBridgingRequestCard } = useShouldShowBridgingRequestCard(request, dappUrl) + const card = useMemo( + (): IntroCardProps => ({ + graphic: { + type: IntroCardGraphicType.Image, + image: BRIDGING_BANNER, + }, + title: t('dapp.request.bridge.title'), + description: t('dapp.request.bridge.description', { numChains: numBridgingChains }), + cardType: CardType.Dismissible, + loggingName: DappRequestCardLoggingName.BridgingBanner, + onClose: (): void => { + dispatch(setHasViewedDappRequestBridgingBanner({ dappUrl, hasViewed: true })) + }, + onPress: (): void => { + if (request) { + onCancel(request).catch(() => {}) + } + dispatch(setHasViewedDappRequestBridgingBanner({ dappUrl, hasViewed: true })) + navigateToSwapFlow({ openTokenSelector: CurrencyField.OUTPUT }) + }, + containerProps: { + borderWidth: 0, + backgroundColor: '$surface1', + }, + }), + [t, numBridgingChains, dispatch, dappUrl, onCancel, request, navigateToSwapFlow], + ) + + if (!request || !shouldShowBridgingRequestCard || totalRequestCount !== 1) { + return null + } + + return +} diff --git a/apps/extension/src/app/features/dappRequests/DappRequestQueueContext.tsx b/apps/extension/src/app/features/dappRequests/DappRequestQueueContext.tsx index e4a0ed5c100..2ef1736c483 100644 --- a/apps/extension/src/app/features/dappRequests/DappRequestQueueContext.tsx +++ b/apps/extension/src/app/features/dappRequests/DappRequestQueueContext.tsx @@ -9,12 +9,12 @@ import { } from 'src/app/features/dappRequests/saga' import { DappRequestStoreItem } from 'src/app/features/dappRequests/slice' import { DappResponseType } from 'src/app/features/dappRequests/types/DappRequestTypes' -import { extractBaseUrl } from 'src/app/features/dappRequests/utils' import { ExtensionState } from 'src/store/extensionReducer' import { ExtensionEventName } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import { DappRequestAction } from 'uniswap/src/features/telemetry/types' import { TransactionTypeInfo } from 'uniswap/src/features/transactions/types/transactionDetails' +import { extractBaseUrl } from 'utilities/src/format/urls' import { Account } from 'wallet/src/features/wallet/accounts/types' import { useActiveAccountWithThrow } from 'wallet/src/features/wallet/hooks' diff --git a/apps/extension/src/app/features/dappRequests/accounts.ts b/apps/extension/src/app/features/dappRequests/accounts.ts index c96a5293ed8..890bcefdea4 100644 --- a/apps/extension/src/app/features/dappRequests/accounts.ts +++ b/apps/extension/src/app/features/dappRequests/accounts.ts @@ -13,7 +13,6 @@ import { GetAccountRequest, RequestAccountRequest, } from 'src/app/features/dappRequests/types/DappRequestTypes' -import { extractBaseUrl } from 'src/app/features/dappRequests/utils' import { dappResponseMessageChannel } from 'src/background/messagePassing/messageChannels' import { call, put, select } from 'typed-redux-saga' import { chainIdToHexadecimalString } from 'uniswap/src/features/chains/utils' @@ -23,6 +22,7 @@ import { getEnabledChainIdsSaga } from 'uniswap/src/features/settings/saga' import { ExtensionEventName } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import { UniverseChainId } from 'uniswap/src/types/chains' +import { extractBaseUrl } from 'utilities/src/format/urls' import { getProvider } from 'wallet/src/features/wallet/context' import { selectActiveAccount } from 'wallet/src/features/wallet/selectors' @@ -61,6 +61,7 @@ function sendAccountResponseAnalyticsEvent( connectedAddresses: accountResponse.connectedAddresses, }) } + /** * Gets the active account, and returns the account address, chainId, and providerUrl. * Chain id + provider url are from the last connected chain for the dApp and wallet. If this has not been set, it will be the default chain and provider. diff --git a/apps/extension/src/app/features/dappRequests/hooks.tsx b/apps/extension/src/app/features/dappRequests/hooks.tsx new file mode 100644 index 00000000000..22445e646af --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/hooks.tsx @@ -0,0 +1,44 @@ +import { useMemo } from 'react' +import { useSelector } from 'react-redux' +import { DappRequestStoreItem } from 'src/app/features/dappRequests/slice' +import { + isRequestAccountRequest, + isRequestPermissionsRequest, +} from 'src/app/features/dappRequests/types/DappRequestTypes' +import { getBridgingDappUrls } from 'uniswap/src/features/bridging/constants' +import { useBridgingSupportedChainIds, useNumBridgingChains } from 'uniswap/src/features/bridging/hooks/chains' +import { selectHasViewedDappRequestBridgingBanner } from 'wallet/src/features/behaviorHistory/selectors' +import { WalletState } from 'wallet/src/state/walletReducer' + +export function useShouldShowBridgingRequestCard( + request: DappRequestStoreItem | undefined, + dappUrl: string, +): { + numBridgingChains: number + shouldShowBridgingRequestCard: boolean +} { + const numBridgingChains = useNumBridgingChains() + const bridgingChainIds = useBridgingSupportedChainIds() + const bridgingDappUrls = useMemo(() => getBridgingDappUrls(bridgingChainIds), [bridgingChainIds]) + + const hasViewedDappRequestBridgingBanner = useSelector((state: WalletState) => + selectHasViewedDappRequestBridgingBanner(state, dappUrl), + ) + + const isConnectRequest = useMemo( + () => + (request && (isRequestAccountRequest(request.dappRequest) || isRequestPermissionsRequest(request.dappRequest))) ?? + false, + [request], + ) + + const isBridgingConnectionRequest = useMemo( + () => isConnectRequest && bridgingDappUrls.includes(dappUrl), + [isConnectRequest, bridgingDappUrls, dappUrl], + ) + + return { + numBridgingChains, + shouldShowBridgingRequestCard: isBridgingConnectionRequest && !hasViewedDappRequestBridgingBanner, + } +} diff --git a/apps/extension/src/app/features/dappRequests/permissions.ts b/apps/extension/src/app/features/dappRequests/permissions.ts index 79833c3d1aa..1bc9570a17c 100644 --- a/apps/extension/src/app/features/dappRequests/permissions.ts +++ b/apps/extension/src/app/features/dappRequests/permissions.ts @@ -15,7 +15,6 @@ import { RevokePermissionsRequest, RevokePermissionsResponse, } from 'src/app/features/dappRequests/types/DappRequestTypes' -import { extractBaseUrl } from 'src/app/features/dappRequests/utils' import { dappResponseMessageChannel } from 'src/background/messagePassing/messageChannels' import { Permission } from 'src/contentScript/WindowEthereumRequestTypes' import { ExtensionEthMethods } from 'src/contentScript/methodHandlers/requestMethods' @@ -23,6 +22,7 @@ import { call, put } from 'typed-redux-saga' import { chainIdToHexadecimalString } from 'uniswap/src/features/chains/utils' import { pushNotification } from 'uniswap/src/features/notifications/slice' import { AppNotificationType } from 'uniswap/src/features/notifications/types' +import { extractBaseUrl } from 'utilities/src/format/urls' export function getPermissions(dappUrl: string | undefined, connectedAddresses: Address[] | undefined): Permission[] { const permissions: Permission[] = [] diff --git a/apps/extension/src/app/features/dappRequests/saga.ts b/apps/extension/src/app/features/dappRequests/saga.ts index 44c8ac10dfe..3e2451cdcc5 100644 --- a/apps/extension/src/app/features/dappRequests/saga.ts +++ b/apps/extension/src/app/features/dappRequests/saga.ts @@ -21,7 +21,6 @@ import { UniswapOpenSidebarResponse, } from 'src/app/features/dappRequests/types/DappRequestTypes' import { HexadecimalNumberSchema } from 'src/app/features/dappRequests/types/utilityTypes' -import { extractBaseUrl } from 'src/app/features/dappRequests/utils' import { isWalletUnlocked } from 'src/app/hooks/useIsWalletUnlocked' import { AppRoutes, HomeQueryParams } from 'src/app/navigation/constants' import { navigate } from 'src/app/navigation/state' @@ -35,6 +34,7 @@ import { TransactionType, TransactionTypeInfo, } from 'uniswap/src/features/transactions/types/transactionDetails' +import { extractBaseUrl } from 'utilities/src/format/urls' import { logger } from 'utilities/src/logger/logger' import { SendTransactionParams, sendTransaction } from 'wallet/src/features/transactions/sendTransactionSaga' import { getProvider, getSignerManager } from 'wallet/src/features/wallet/context' diff --git a/apps/extension/src/app/features/dappRequests/utils.ts b/apps/extension/src/app/features/dappRequests/utils.ts deleted file mode 100644 index b0363032776..00000000000 --- a/apps/extension/src/app/features/dappRequests/utils.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { logger } from 'utilities/src/logger/logger' - -function parseUrl(url?: string): URL | undefined { - if (!url) { - return undefined - } - - try { - return new URL(url) - } catch (error) { - logger.error(error, { - tags: { file: 'dappRequests/utils', function: 'extractBaseUrl' }, - extra: { url }, - }) - return undefined - } -} - -/** Returns the url host (doesn't include http or https) */ -export function extractUrlHost(url?: string): string | undefined { - return parseUrl(url)?.host -} - -/** Returns the url origin (includes http or https) */ -export function extractBaseUrl(url?: string): string | undefined { - return parseUrl(url)?.origin -} diff --git a/apps/extension/src/app/features/home/SwitchNetworksModal.tsx b/apps/extension/src/app/features/home/SwitchNetworksModal.tsx index 0fbcc0c7f5b..ab3d2c17890 100644 --- a/apps/extension/src/app/features/home/SwitchNetworksModal.tsx +++ b/apps/extension/src/app/features/home/SwitchNetworksModal.tsx @@ -3,7 +3,6 @@ import { useDispatch } from 'react-redux' import { useDappContext } from 'src/app/features/dapp/DappContext' import { removeDappConnection, saveDappChain } from 'src/app/features/dapp/actions' import { useDappLastChainId } from 'src/app/features/dapp/hooks' -import { extractUrlHost } from 'src/app/features/dappRequests/utils' import { PopupName, closePopup } from 'src/app/features/popups/slice' import { Anchor, Button, Flex, Popover, Separator, Text, getTokenValue } from 'ui/src' import { Check, Power } from 'ui/src/components/icons' @@ -17,6 +16,7 @@ import { useEnabledChains } from 'uniswap/src/features/settings/hooks' import { ExtensionEventName } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import { UniverseChainId } from 'uniswap/src/types/chains' +import { extractUrlHost } from 'utilities/src/format/urls' import { useActiveAccountWithThrow } from 'wallet/src/features/wallet/hooks' const BUTTON_OFFSET = 20 diff --git a/apps/extension/src/app/features/home/TokenBalanceList.tsx b/apps/extension/src/app/features/home/TokenBalanceList.tsx index 43f27745c3e..e66c4510569 100644 --- a/apps/extension/src/app/features/home/TokenBalanceList.tsx +++ b/apps/extension/src/app/features/home/TokenBalanceList.tsx @@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next' import { useInterfaceBuyNavigator } from 'src/app/features/for/utils' import { AppRoutes } from 'src/app/navigation/constants' import { navigate } from 'src/app/navigation/state' -import { AnimatePresence, ContextMenu, Flex, Loader } from 'ui/src' +import { AnimatePresence, Flex, Loader } from 'ui/src' import { ShieldCheck } from 'ui/src/components/icons' import { BaseCard } from 'uniswap/src/components/BaseCard/BaseCard' import { InfoLinkModal } from 'uniswap/src/components/modals/InfoLinkModal' @@ -15,6 +15,7 @@ import { useEnabledChains } from 'uniswap/src/features/settings/hooks' import { ElementName, ModalName, SectionName, WalletEventName } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import { InformationBanner } from 'wallet/src/components/banners/InformationBanner' +import { ContextMenu } from 'wallet/src/components/menu/ContextMenu' import { useWalletNavigation } from 'wallet/src/contexts/WalletNavigationContext' import { isNonPollingRequestInFlight } from 'wallet/src/data/utils' import { HiddenTokensRow } from 'wallet/src/features/portfolio/HiddenTokensRow' @@ -211,7 +212,12 @@ const TokenBalanceItemRow = memo(function TokenBalanceItemRow({ item }: { item: return ( - + ) }) diff --git a/apps/extension/src/app/features/home/introCards/HomeIntroCardStack.tsx b/apps/extension/src/app/features/home/introCards/HomeIntroCardStack.tsx index da99acf6bd5..c1a16c8e0cf 100644 --- a/apps/extension/src/app/features/home/introCards/HomeIntroCardStack.tsx +++ b/apps/extension/src/app/features/home/introCards/HomeIntroCardStack.tsx @@ -1,4 +1,5 @@ import { useCallback } from 'react' +import { UnitagClaimRoutes } from 'src/app/navigation/constants' import { focusOrCreateUnitagTab } from 'src/app/navigation/utils' import { Flex } from 'ui/src' import { PollingInterval } from 'uniswap/src/constants/misc' @@ -18,8 +19,8 @@ export function HomeIntroCardStack(): JSX.Element | null { }) const navigateToUnitagClaim = useCallback(async () => { - await focusOrCreateUnitagTab() - }, []) + await focusOrCreateUnitagTab(activeAccount.address, UnitagClaimRoutes.ClaimIntro) + }, [activeAccount.address]) const { cards } = useSharedIntroCards({ navigateToUnitagClaim, diff --git a/apps/extension/src/app/features/onboarding/ClaimUnitagScreen.tsx b/apps/extension/src/app/features/onboarding/ClaimUnitagScreen.tsx index 4f6a50f2e43..18c19d99e23 100644 --- a/apps/extension/src/app/features/onboarding/ClaimUnitagScreen.tsx +++ b/apps/extension/src/app/features/onboarding/ClaimUnitagScreen.tsx @@ -15,12 +15,16 @@ import { ClaimUnitagContent } from 'wallet/src/features/unitags/ClaimUnitagConte export function ClaimUnitagScreen(): JSX.Element { const { t } = useTranslation() const { goToNextStep } = useOnboardingSteps() - const { resetOnboardingContextData, getOnboardingAccountAddress } = useOnboardingContext() + const { resetOnboardingContextData, getOnboardingAccountAddress, addUnitagClaim } = useOnboardingContext() const onboardingAccountAddress = getOnboardingAccountAddress() - const onNextStep = useCallback(async () => { - goToNextStep() - }, [goToNextStep]) + const onComplete = useCallback( + (unitag: string) => { + addUnitagClaim({ username: unitag }) + goToNextStep() + }, + [goToNextStep, addUnitagClaim], + ) const handleBack = useCallback(() => { // reset the pending mnemonic when going back from password screen @@ -44,14 +48,14 @@ export function ClaimUnitagScreen(): JSX.Element { subtitle={t('unitags.onboarding.claim.subtitle')} title={t('unitags.onboarding.claim.title.choose')} onBack={handleBack} - onSkip={onNextStep} + onSkip={goToNextStep} > diff --git a/apps/extension/src/app/features/onboarding/Complete.tsx b/apps/extension/src/app/features/onboarding/Complete.tsx index dc13162a5e3..dc13ece7777 100644 --- a/apps/extension/src/app/features/onboarding/Complete.tsx +++ b/apps/extension/src/app/features/onboarding/Complete.tsx @@ -15,15 +15,35 @@ import { iconSizes } from 'ui/src/theme' import { uniswapUrls } from 'uniswap/src/constants/urls' import { ExtensionOnboardingFlow } from 'uniswap/src/types/screens/extension' import { logger } from 'utilities/src/logger/logger' -import { useFinishOnboarding } from 'wallet/src/features/onboarding/OnboardingContext' +import { useFinishOnboarding, useOnboardingContext } from 'wallet/src/features/onboarding/OnboardingContext' -export function Complete({ flow }: { flow?: ExtensionOnboardingFlow }): JSX.Element { +export function Complete({ + flow, + tryToClaimUnitag, +}: { + flow?: ExtensionOnboardingFlow + tryToClaimUnitag?: boolean +}): JSX.Element { const { t } = useTranslation() - + const { getOnboardingAccountAddress, addUnitagClaim, getUnitagClaim } = useOnboardingContext() + const address = getOnboardingAccountAddress() + const existingClaim = getUnitagClaim() + const [unitagClaimAttempted, setUnitagClaimAttempted] = useState(false) const [openedSideBar, setOpenedSideBar] = useState(false) + useEffect(() => { + if (!tryToClaimUnitag || !address || unitagClaimAttempted) { + return + } + + setUnitagClaimAttempted(true) + if (existingClaim?.username) { + addUnitagClaim({ address, username: existingClaim.username }) + } + }, [existingClaim, address, tryToClaimUnitag, unitagClaimAttempted, addUnitagClaim]) + // Activates onboarding accounts on component mount - useFinishOnboarding(terminateStoreSynchronization, flow) + useFinishOnboarding(terminateStoreSynchronization, flow, tryToClaimUnitag && !unitagClaimAttempted) useEffect(() => { const onSidebarOpenedListener = onboardingMessageChannel.addMessageListener( diff --git a/apps/extension/src/app/features/onboarding/reset/ResetComplete.tsx b/apps/extension/src/app/features/onboarding/reset/ResetComplete.tsx index 6b185100c61..b21f911f31f 100644 --- a/apps/extension/src/app/features/onboarding/reset/ResetComplete.tsx +++ b/apps/extension/src/app/features/onboarding/reset/ResetComplete.tsx @@ -1,7 +1,9 @@ import { useTranslation } from 'react-i18next' +import { Link } from 'react-router-dom' import { terminateStoreSynchronization } from 'src/store/storeSynchronization' import { Flex, Text } from 'ui/src' import { Check, GraduationCap } from 'ui/src/components/icons' +import { uniswapUrls } from 'uniswap/src/constants/urls' import { useFinishOnboarding } from 'wallet/src/features/onboarding/OnboardingContext' export function ResetComplete(): JSX.Element { @@ -22,12 +24,18 @@ export function ResetComplete(): JSX.Element { {t('onboarding.resetPassword.complete.subtitle')} - - - - {t('onboarding.resetPassword.complete.safety')} - - + + + + + {t('onboarding.resetPassword.complete.safety')} + + + ) diff --git a/apps/extension/src/app/features/popups/ConnectPopup.tsx b/apps/extension/src/app/features/popups/ConnectPopup.tsx index 8b5055a4737..1050d305736 100644 --- a/apps/extension/src/app/features/popups/ConnectPopup.tsx +++ b/apps/extension/src/app/features/popups/ConnectPopup.tsx @@ -1,13 +1,13 @@ import { useTranslation } from 'react-i18next' import { Link } from 'react-router-dom' -import { saveDappConnection } from 'src/app/features/dapp/actions' import { useDappContext } from 'src/app/features/dapp/DappContext' -import { extractUrlHost } from 'src/app/features/dappRequests/utils' +import { saveDappConnection } from 'src/app/features/dapp/actions' import { Anchor, Button, Flex, Popover, Separator, Text, TouchableArea } from 'ui/src' import { X } from 'ui/src/components/icons' import { uniswapUrls } from 'uniswap/src/constants/urls' import { ExtensionEventName } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { extractUrlHost } from 'utilities/src/format/urls' import { useActiveAccountWithThrow } from 'wallet/src/features/wallet/hooks' export function ConnectPopupContent({ diff --git a/apps/extension/src/app/features/send/SendFormScreen/RecipientPanel.tsx b/apps/extension/src/app/features/send/SendFormScreen/RecipientPanel.tsx index e095a26bb55..196093271d0 100644 --- a/apps/extension/src/app/features/send/SendFormScreen/RecipientPanel.tsx +++ b/apps/extension/src/app/features/send/SendFormScreen/RecipientPanel.tsx @@ -35,7 +35,7 @@ export function RecipientPanel({ chainId }: RecipientPanelProps): JSX.Element { onSetShowRecipientSelector(!showRecipientSelector) }, [onSetShowRecipientSelector, showRecipientSelector]) - const sections = useFilteredRecipientSections(pattern) + const { sections } = useFilteredRecipientSections(pattern) const onSelectRecipient = useCallback((newRecipient: string) => { setSelectedRecipient(newRecipient) diff --git a/apps/extension/src/app/features/settings/SettingsManageConnectionsScreen/SettingsManageConnectionsScreen.tsx b/apps/extension/src/app/features/settings/SettingsManageConnectionsScreen/SettingsManageConnectionsScreen.tsx index 5d94501a3cd..3b59c486275 100644 --- a/apps/extension/src/app/features/settings/SettingsManageConnectionsScreen/SettingsManageConnectionsScreen.tsx +++ b/apps/extension/src/app/features/settings/SettingsManageConnectionsScreen/SettingsManageConnectionsScreen.tsx @@ -5,7 +5,6 @@ import { ScreenHeader } from 'src/app/components/layout/ScreenHeader' import { removeDappConnection } from 'src/app/features/dapp/actions' import { useAllDappConnectionsForActiveAccount } from 'src/app/features/dapp/hooks' import { dappStore } from 'src/app/features/dapp/store' -import { extractUrlHost } from 'src/app/features/dappRequests/utils' import { EllipsisDropdown } from 'src/app/features/settings/SettingsManageConnectionsScreen/internal/EllipsisDropdown' import { NoDappConnections } from 'src/app/features/settings/SettingsManageConnectionsScreen/internal/NoDappConnections' import { Flex, Text, TouchableArea, UniversalImage, useSporeColors } from 'ui/src' @@ -18,6 +17,7 @@ import { ExtensionEventName } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import { ExtensionScreens } from 'uniswap/src/types/screens/extension' import { extractNameFromUrl } from 'utilities/src/format/extractNameFromUrl' +import { extractUrlHost } from 'utilities/src/format/urls' import { DappIconPlaceholder } from 'wallet/src/components/WalletConnect/DappIconPlaceholder' import { useActiveAccountWithThrow } from 'wallet/src/features/wallet/hooks' diff --git a/apps/extension/src/app/features/settings/SettingsManageConnectionsScreen/internal/EllipsisDropdown.tsx b/apps/extension/src/app/features/settings/SettingsManageConnectionsScreen/internal/EllipsisDropdown.tsx index c31f657a790..3b2bf950810 100644 --- a/apps/extension/src/app/features/settings/SettingsManageConnectionsScreen/internal/EllipsisDropdown.tsx +++ b/apps/extension/src/app/features/settings/SettingsManageConnectionsScreen/internal/EllipsisDropdown.tsx @@ -1,12 +1,13 @@ import { useTranslation } from 'react-i18next' import { useDispatch } from 'react-redux' import { removeAllDappConnectionsForAccount } from 'src/app/features/dapp/actions' -import { ContextMenu, Flex, TouchableArea } from 'ui/src' +import { Flex, TouchableArea } from 'ui/src' import { Ellipsis, Power } from 'ui/src/components/icons' import { pushNotification } from 'uniswap/src/features/notifications/slice' import { AppNotificationType } from 'uniswap/src/features/notifications/types' import { ExtensionEventName } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { ContextMenu } from 'wallet/src/components/menu/ContextMenu' import { useActiveAccountAddress, useActiveAccountWithThrow } from 'wallet/src/features/wallet/hooks' const PowerCircle = (): JSX.Element => ( diff --git a/apps/extension/src/app/features/settings/SettingsRecoveryPhraseScreen/SettingsRecoveryPhrase.tsx b/apps/extension/src/app/features/settings/SettingsRecoveryPhraseScreen/SettingsRecoveryPhrase.tsx index 654fb831a27..f5f0f169599 100644 --- a/apps/extension/src/app/features/settings/SettingsRecoveryPhraseScreen/SettingsRecoveryPhrase.tsx +++ b/apps/extension/src/app/features/settings/SettingsRecoveryPhraseScreen/SettingsRecoveryPhrase.tsx @@ -37,7 +37,7 @@ export function SettingsRecoveryPhrase({ {children} - + - + + + + + + diff --git a/apps/mobile/src/app/modals/BackupWarningModal.tsx b/apps/mobile/src/app/modals/BackupWarningModal.tsx index bffdea5c591..d2b6e391bba 100644 --- a/apps/mobile/src/app/modals/BackupWarningModal.tsx +++ b/apps/mobile/src/app/modals/BackupWarningModal.tsx @@ -4,7 +4,8 @@ import { useDispatch } from 'react-redux' import { closeModal, openModal } from 'src/features/modals/modalSlice' import { WarningModal } from 'uniswap/src/components/modals/WarningModal/WarningModal' import { WarningSeverity } from 'uniswap/src/components/modals/WarningModal/types' -import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { ElementName, ModalName, WalletEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import { setBackupReminderLastSeenTs } from 'wallet/src/features/behaviorHistory/slice' export function BackupWarningModal(): JSX.Element { @@ -17,11 +18,18 @@ export function BackupWarningModal(): JSX.Element { } const checkForSwipeToDismiss = (): void => { - if (!closedByButtonRef.current) { + const markReminderAsSeen = !closedByButtonRef.current + if (markReminderAsSeen) { // Modal was swiped to dismiss, should set backup reminder timestamp dispatch(setBackupReminderLastSeenTs(Date.now())) } + sendAnalyticsEvent(WalletEventName.ModalClosed, { + element: ElementName.BackButton, + modal: ModalName.BackupReminderWarning, + markReminderAsSeen, + }) + // Reset the ref and close the modal closedByButtonRef.current = false onClose() diff --git a/apps/mobile/src/app/navigation/NavigationContainer.tsx b/apps/mobile/src/app/navigation/NavigationContainer.tsx index 46fa51f1cba..7878f747adb 100644 --- a/apps/mobile/src/app/navigation/NavigationContainer.tsx +++ b/apps/mobile/src/app/navigation/NavigationContainer.tsx @@ -17,7 +17,7 @@ import { processWidgetEvents } from 'src/features/widgets/widgets' import { useSporeColors } from 'ui/src' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import Trace from 'uniswap/src/features/telemetry/Trace' -import { MobileAppScreen } from 'uniswap/src/types/screens/mobile' +import { MobileNavScreen } from 'uniswap/src/types/screens/mobile' import { useAsyncData } from 'utilities/src/react/hooks' import { sleep } from 'utilities/src/time/timing' @@ -30,7 +30,7 @@ export const navigationRef = createNavigationContainerRef() /** Wrapped `NavigationContainer` with telemetry tracing. */ export const NavigationContainer: FC> = ({ children, onReady }: PropsWithChildren) => { const colors = useSporeColors() - const [routeName, setRouteName] = useState() + const [routeName, setRouteName] = useState() const [routeParams, setRouteParams] = useState | undefined>() const [logImpression, setLogImpression] = useState(false) @@ -51,7 +51,7 @@ export const NavigationContainer: FC> = ({ children, on processWidgetEvents().catch(() => undefined) // setting initial route name for telemetry - const initialRoute = navigationRef.getCurrentRoute()?.name as MobileAppScreen + const initialRoute = navigationRef.getCurrentRoute()?.name as MobileNavScreen setRouteName(initialRoute) if (!__DEV__) { @@ -60,7 +60,7 @@ export const NavigationContainer: FC> = ({ children, on }} onStateChange={(): void => { const previousRouteName = routeName - const currentRouteName: MobileAppScreen = navigationRef.getCurrentRoute()?.name as MobileAppScreen + const currentRouteName: MobileNavScreen = navigationRef.getCurrentRoute()?.name as MobileNavScreen if ( currentRouteName && @@ -69,7 +69,7 @@ export const NavigationContainer: FC> = ({ children, on ) { const currentRouteParams = getEventParams( currentRouteName, - navigationRef.getCurrentRoute()?.params as RootParamList[MobileAppScreen], + navigationRef.getCurrentRoute()?.params as RootParamList[MobileNavScreen], ) setLogImpression(true) setRouteName(currentRouteName) diff --git a/apps/mobile/src/components/PriceExplorer/PriceExplorerAnimatedNumber.tsx b/apps/mobile/src/components/PriceExplorer/PriceExplorerAnimatedNumber.tsx index cfa5669fd41..b0d2250828a 100644 --- a/apps/mobile/src/components/PriceExplorer/PriceExplorerAnimatedNumber.tsx +++ b/apps/mobile/src/components/PriceExplorer/PriceExplorerAnimatedNumber.tsx @@ -1,5 +1,5 @@ import { SCREEN_WIDTH } from '@gorhom/bottom-sheet' -import _ from 'lodash' +import times from 'lodash/times' import React, { useEffect, useState } from 'react' import { StyleSheet, Text, View } from 'react-native' import Animated, { @@ -252,26 +252,23 @@ const Numbers = ({ const commaIndex = numberOfDigits.left + Math.floor((numberOfDigits.left - 1) / 3) - return _.times( - numberOfDigits.left + numberOfDigits.right + Math.floor((numberOfDigits.left - 1) / 3) + 1, - (index) => ( - ( + + - - - ), - ) + chars={chars} + commaIndex={commaIndex} + currency={currency} + decimalPlace={decimalPlace} + hidePlaceholder={hidePlaceholder} + index={index} + shouldAnimate={price.shouldAnimate} + /> + + )) } const LoadingWrapper = (): JSX.Element | null => { diff --git a/apps/mobile/src/components/PriceExplorer/usePriceHistory.ts b/apps/mobile/src/components/PriceExplorer/usePriceHistory.ts index ec27fd25173..79d1cc41ccf 100644 --- a/apps/mobile/src/components/PriceExplorer/usePriceHistory.ts +++ b/apps/mobile/src/components/PriceExplorer/usePriceHistory.ts @@ -1,4 +1,4 @@ -import { maxBy } from 'lodash' +import maxBy from 'lodash/maxBy' import { Dispatch, SetStateAction, useCallback, useMemo, useRef, useState } from 'react' import { SharedValue, useDerivedValue } from 'react-native-reanimated' import { TLineChartData } from 'react-native-wagmi-charts' diff --git a/apps/mobile/src/components/RecipientSelect/RecipientSelect.tsx b/apps/mobile/src/components/RecipientSelect/RecipientSelect.tsx index d8c4507a7d9..cc9a3e69b2b 100644 --- a/apps/mobile/src/components/RecipientSelect/RecipientSelect.tsx +++ b/apps/mobile/src/components/RecipientSelect/RecipientSelect.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next' import { TextInput } from 'react-native' import { FadeIn, FadeOut } from 'react-native-reanimated' import { RecipientScanModal } from 'src/components/RecipientSelect/RecipientScanModal' -import { Flex, Text, TouchableArea, useSporeColors } from 'ui/src' +import { Flex, Loader, Text, TouchableArea, useSporeColors } from 'ui/src' import ScanQRIcon from 'ui/src/assets/icons/scan.svg' import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' import { iconSizes } from 'ui/src/theme' @@ -51,7 +51,7 @@ export function _RecipientSelect({ const [showQRScanner, setShowQRScanner] = useState(false) const [checkSpeedBumps, setCheckSpeedBumps] = useState(false) const [selectedRecipient, setSelectedRecipient] = useState(recipient) - const sections = useFilteredRecipientSections(pattern) + const { sections, loading } = useFilteredRecipientSections(pattern) useEffect(() => { if (focusInput) { @@ -104,7 +104,9 @@ export function _RecipientSelect({ onBack={recipient ? onHideRecipientSelector : undefined} onChangeText={setPattern} /> - {!sections.length ? ( + {loading ? ( + + ) : !sections.length ? ( {t('send.recipient.results.empty')} @@ -112,7 +114,6 @@ export function _RecipientSelect({ ) : ( - // Show either suggested recipients or filtered sections based on query )} diff --git a/apps/mobile/src/components/RemoveWallet/RemoveWalletModal.tsx b/apps/mobile/src/components/RemoveWallet/RemoveWalletModal.tsx index b8a17caaa83..fa48133c537 100644 --- a/apps/mobile/src/components/RemoveWallet/RemoveWalletModal.tsx +++ b/apps/mobile/src/components/RemoveWallet/RemoveWalletModal.tsx @@ -20,6 +20,7 @@ import { ElementName, ModalName, WalletEventName } from 'uniswap/src/features/te import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import { ImportType, OnboardingEntryPoint } from 'uniswap/src/types/onboarding' import { MobileScreens, OnboardingScreens } from 'uniswap/src/types/screens/mobile' +import { areAddressesEqual } from 'uniswap/src/utils/addresses' import { logger } from 'utilities/src/logger/logger' import { Keyring } from 'wallet/src/features/wallet/Keyring/Keyring' import { EditAccountAction, editAccountActions } from 'wallet/src/features/wallet/accounts/editAccountSaga' @@ -43,7 +44,8 @@ export function RemoveWalletModal(): JSX.Element | null { // This happens when user wants to replace mnemonic with a new one const isReplacing = !address - const isRemovingMnemonic = Boolean(associatedAccounts.find((acc) => address === acc.address)) + const isRemovingMnemonic = Boolean(associatedAccounts.find((acc) => areAddressesEqual(address, acc.address))) + const isRemovingLastMnemonic = isRemovingMnemonic && associatedAccounts.length === 1 const isRemovingRecoveryPhrase = isReplacing || isRemovingLastMnemonic diff --git a/apps/mobile/src/components/Requests/ScanSheet/SwitchAccountOption.tsx b/apps/mobile/src/components/Requests/ScanSheet/SwitchAccountOption.tsx index ab322ee9d4a..7e6f9695c59 100644 --- a/apps/mobile/src/components/Requests/ScanSheet/SwitchAccountOption.tsx +++ b/apps/mobile/src/components/Requests/ScanSheet/SwitchAccountOption.tsx @@ -1,7 +1,7 @@ import React from 'react' import { Flex, Separator, Text, Unicon, useSporeColors } from 'ui/src' import Check from 'ui/src/assets/icons/check.svg' -import { shortenAddress } from 'uniswap/src/utils/addresses' +import { areAddressesEqual, shortenAddress } from 'uniswap/src/utils/addresses' import { DisplayNameText } from 'wallet/src/components/accounts/DisplayNameText' import { Account } from 'wallet/src/features/wallet/accounts/types' import { useDisplayName } from 'wallet/src/features/wallet/hooks' @@ -32,7 +32,7 @@ export const SwitchAccountOption = ({ account, activeAccount }: Props): JSX.Elem - {activeAccount?.address === account.address && ( + {areAddressesEqual(activeAccount?.address, account.address) && ( )} diff --git a/apps/mobile/src/components/Settings/SettingsRow.tsx b/apps/mobile/src/components/Settings/SettingsRow.tsx index d703dcbf6d1..8726645837d 100644 --- a/apps/mobile/src/components/Settings/SettingsRow.tsx +++ b/apps/mobile/src/components/Settings/SettingsRow.tsx @@ -109,8 +109,8 @@ export function SettingsRow({ ) : screen || modal ? ( {currentSetting ? ( - - + + {currentSetting} diff --git a/apps/mobile/src/components/TokenBalanceList/TokenBalanceItemContextMenu.tsx b/apps/mobile/src/components/TokenBalanceList/TokenBalanceItemContextMenu.tsx index d83a279ebc5..cfa0140fbf0 100644 --- a/apps/mobile/src/components/TokenBalanceList/TokenBalanceItemContextMenu.tsx +++ b/apps/mobile/src/components/TokenBalanceList/TokenBalanceItemContextMenu.tsx @@ -1,4 +1,4 @@ -import React, { memo, useMemo } from 'react' +import React, { PropsWithChildren, memo, useMemo } from 'react' import { useTranslation } from 'react-i18next' import ContextMenu from 'react-native-context-menu-view' import { borderRadii } from 'ui/src/theme' @@ -6,13 +6,12 @@ import { SafetyLevel } from 'uniswap/src/data/graphql/uniswap-data-api/__generat import { PortfolioBalance } from 'uniswap/src/features/dataApi/types' import { useTokenContextMenu } from 'wallet/src/features/portfolio/useTokenContextMenu' -export const TokenBalanceItemContextMenu = memo(function _TokenBalanceItem({ +export const TokenBalanceItemContextMenu = memo(function _TokenBalanceItemContextMenu({ portfolioBalance, children, -}: { +}: PropsWithChildren<{ portfolioBalance: PortfolioBalance - children: React.ReactNode -}) { +}>) { const { t } = useTranslation() const { menuActions, onContextMenuPress } = useTokenContextMenu({ diff --git a/apps/mobile/src/components/TokenBalanceList/TokenBalanceList.tsx b/apps/mobile/src/components/TokenBalanceList/TokenBalanceList.tsx index 2c620f19ab1..02daa84880b 100644 --- a/apps/mobile/src/components/TokenBalanceList/TokenBalanceList.tsx +++ b/apps/mobile/src/components/TokenBalanceList/TokenBalanceList.tsx @@ -1,7 +1,7 @@ import { BottomSheetFlatList } from '@gorhom/bottom-sheet' import { useFocusEffect } from '@react-navigation/core' import { ReactNavigationPerformanceView } from '@shopify/react-native-performance-navigation' -import { forwardRef, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { forwardRef, memo, useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { FlatList, RefreshControl } from 'react-native' import Animated, { FadeInDown, FadeOut } from 'react-native-reanimated' @@ -22,6 +22,7 @@ import { useAppInsets } from 'uniswap/src/hooks/useAppInsets' import { CurrencyId } from 'uniswap/src/types/currency' import { MobileScreens } from 'uniswap/src/types/screens/mobile' import { isAndroid } from 'utilities/src/platform' +import { useValueAsRef } from 'utilities/src/react/useValueAsRef' import { InformationBanner } from 'wallet/src/components/banners/InformationBanner' import { isError, isNonPollingRequestInFlight } from 'wallet/src/data/utils' import { HiddenTokensRow } from 'wallet/src/features/portfolio/HiddenTokensRow' @@ -72,12 +73,10 @@ export const TokenBalanceListInner = forwardRef, T }, ref, ) { - const { t } = useTranslation() const colors = useSporeColors() const insets = useAppInsets() - const { rows, balancesById, networkStatus, refetch } = useTokenBalanceListContext() - const hasError = isError(networkStatus, !!balancesById) + const { rows, balancesById } = useTokenBalanceListContext() const { onContentSizeChange, adaptiveFooter } = useAdaptiveFooter(containerProps?.contentContainerStyle) @@ -90,8 +89,7 @@ export const TokenBalanceListInner = forwardRef, T const [isFocused, setIsFocused] = useState(true) const [cachedRows, setCachedRows] = useState(null) - const rowsRef = useRef(rows) - rowsRef.current = rows + const rowsRef = useValueAsRef(rows) useFocusEffect( useCallback(() => { @@ -101,7 +99,7 @@ export const TokenBalanceListInner = forwardRef, T setCachedRows(rowsRef.current) setIsFocused(false) } - }, []), + }, [rowsRef]), ) const navigation = useAppStackNavigation() @@ -132,49 +130,19 @@ export const TokenBalanceListInner = forwardRef, T // In order to avoid unnecessary re-renders of the entire FlatList, the `renderItem` function should never change. // That's why we use a context provider so that each row can read from there instead of passing down new props every time the data changes. const renderItem = useCallback( - ({ item, index }: { item: TokenBalanceListRow; index: number }): JSX.Element => ( - - ), + ({ item }: { item: TokenBalanceListRow }): JSX.Element => , [], ) const keyExtractor = useCallback((item: TokenBalanceListRow): string => item, []) const ListEmptyComponent = useMemo(() => { - if (hasError) { - return ( - - refetch?.()} - /> - - ) - } - - if (isNonPollingRequestInFlight(networkStatus)) { - return ( - - - - ) - } - - return ( - - {empty} - - ) - }, [hasError, empty, t, networkStatus, refetch]) + return + }, [empty]) const ListHeaderComponent = useMemo(() => { - return hasError ? ( - - - - ) : null - }, [hasError, refetch, t]) + return + }, []) // add negative z index to prevent footer from covering hidden tokens row when minimized const ListFooterComponentStyle = useMemo(() => ({ zIndex: zIndices.negative }), []) @@ -232,79 +200,81 @@ export const TokenBalanceListInner = forwardRef, T }, ) -const TokenBalanceItemRow = memo(function TokenBalanceItemRow({ - item, - index, -}: { - item: TokenBalanceListRow - index?: number -}) { - const { - balancesById, - hiddenTokensCount, - hiddenTokensExpanded, - isWarmLoading, - onPressToken, - setHiddenTokensExpanded, - } = useTokenBalanceListContext() - +const HeaderComponent = memo(function _HeaderComponent(): JSX.Element | null { const { t } = useTranslation() - const [isModalVisible, setModalVisible] = useState(false) + const { balancesById, networkStatus, refetch } = useTokenBalanceListContext() + const hasError = isError(networkStatus, !!balancesById) + + return hasError ? ( + + + + ) : null +}) - const handlePressToken = (): void => { - setModalVisible(true) - } +const EmptyComponent = memo(function _EmptyComponent({ + renderEmpty, +}: { + renderEmpty?: JSX.Element | null +}): JSX.Element { + const { t } = useTranslation() + const { balancesById, networkStatus, refetch } = useTokenBalanceListContext() - const closeModal = (): void => { - setModalVisible(false) - } + const shouldShowLoaderSkeleton = isNonPollingRequestInFlight(networkStatus) + const hasError = isError(networkStatus, !!balancesById) - const handleAnalytics = (): void => { - sendAnalyticsEvent(WalletEventName.ExternalLinkOpened, { - url: uniswapUrls.helpArticleUrls.hiddenTokenInfo, - }) + if (hasError) { + return ( + + refetch?.()} + /> + + ) } - if (item === HIDDEN_TOKEN_BALANCES_ROW) { + if (shouldShowLoaderSkeleton) { return ( - - { - setHiddenTokensExpanded(!hiddenTokensExpanded) - }} - /> - {hiddenTokensExpanded && ( - - - - )} - - - - - } - isOpen={isModalVisible} - linkText={t('common.button.learn')} - linkUrl={uniswapUrls.helpArticleUrls.hiddenTokenInfo} - name={ModalName.HiddenTokenInfoModal} - title={t('hidden.tokens.info.text.title')} - onAnalyticsEvent={handleAnalytics} - onButtonPress={closeModal} - onDismiss={closeModal} - /> + + ) } + return ( + + {renderEmpty} + + ) +}) + +const TokenBalanceItemRow = memo(function TokenBalanceItemRow({ item }: { item: TokenBalanceListRow }) { + const { balancesById, isWarmLoading, onPressToken } = useTokenBalanceListContext() + const portfolioBalance = balancesById?.[item] + const hasPortfolioBalance = !!portfolioBalance + + const tokenBalanceItem = useMemo(() => { + if (!hasPortfolioBalance) { + return null + } + + return ( + + ) + }, [hasPortfolioBalance, portfolioBalance?.id, portfolioBalance?.currencyInfo, isWarmLoading, onPressToken]) + + if (item === HIDDEN_TOKEN_BALANCES_ROW) { + return + } if (!portfolioBalance) { // This can happen when the view is out of focus and the user sells/sends 100% of a token's balance. @@ -318,14 +288,65 @@ const TokenBalanceItemRow = memo(function TokenBalanceItemRow({ } return ( - - {tokenBalanceItem} + ) +}) + +const HiddenTokensRowWrapper = memo(function HiddenTokensRowWrapper(): JSX.Element { + const { t } = useTranslation() + + const { hiddenTokensCount, hiddenTokensExpanded, setHiddenTokensExpanded } = useTokenBalanceListContext() + + const [isModalVisible, setModalVisible] = useState(false) + + const handlePressToken = useCallback((): void => { + setModalVisible(true) + }, []) + + const closeModal = useCallback((): void => { + setModalVisible(false) + }, []) + + const handleAnalytics = useCallback((): void => { + sendAnalyticsEvent(WalletEventName.ExternalLinkOpened, { + url: uniswapUrls.helpArticleUrls.hiddenTokenInfo, + }) + }, []) + + return ( + + { + setHiddenTokensExpanded(!hiddenTokensExpanded) + }} + /> + {hiddenTokensExpanded && ( + + + + )} + + + + + } + isOpen={isModalVisible} + linkText={t('common.button.learn')} + linkUrl={uniswapUrls.helpArticleUrls.hiddenTokenInfo} + name={ModalName.HiddenTokenInfoModal} + title={t('hidden.tokens.info.text.title')} + onAnalyticsEvent={handleAnalytics} + onButtonPress={closeModal} + onDismiss={closeModal} /> - + ) }) diff --git a/apps/mobile/src/components/TokenDetails/TokenDetailsHeader.tsx b/apps/mobile/src/components/TokenDetails/TokenDetailsHeader.tsx index 27ce9171f76..0f27fa9f8e6 100644 --- a/apps/mobile/src/components/TokenDetails/TokenDetailsHeader.tsx +++ b/apps/mobile/src/components/TokenDetails/TokenDetailsHeader.tsx @@ -7,6 +7,8 @@ import { TokenDetailsScreenQuery, } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' import { fromGraphQLChain } from 'uniswap/src/features/chains/utils' +import { FeatureFlags } from 'uniswap/src/features/gating/flags' +import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { TestID } from 'uniswap/src/test/fixtures/testIDs' export interface TokenDetailsHeaderProps { @@ -20,9 +22,12 @@ export function TokenDetailsHeader({ loading = false, onPressWarningIcon, }: TokenDetailsHeaderProps): JSX.Element { + const tokenProtectionEnabled = useFeatureFlag(FeatureFlags.TokenProtection) const token = data?.token const tokenProject = token?.project - + const shouldShowWarningIcon = + !tokenProtectionEnabled && + (tokenProject?.safetyLevel === SafetyLevel.StrongWarning || tokenProject?.safetyLevel === SafetyLevel.Blocked) return ( {token?.name ?? '—'} - {/* Suppress warning icon on low warning level */} - {(tokenProject?.safetyLevel === SafetyLevel.StrongWarning || - tokenProject?.safetyLevel === SafetyLevel.Blocked) && ( + {shouldShowWarningIcon && ( diff --git a/apps/mobile/src/components/TokenSelector/TokenFiatOnRampList.tsx b/apps/mobile/src/components/TokenSelector/TokenFiatOnRampList.tsx index 0e94ede7e76..4feac9901e0 100644 --- a/apps/mobile/src/components/TokenSelector/TokenFiatOnRampList.tsx +++ b/apps/mobile/src/components/TokenSelector/TokenFiatOnRampList.tsx @@ -48,7 +48,7 @@ function TokenOptionItemWrapper({ [currencyInfo, balanceUSD, quantity, isUnsupported], ) const onPress = useCallback(() => onSelectCurrency?.(currency), [currency, onSelectCurrency]) - const { tokenWarningDismissed, onDismissTokenWarning } = useDismissedTokenWarnings(currencyInfo?.currency) + const { tokenWarningDismissed } = useDismissedTokenWarnings(currencyInfo?.currency) const { convertFiatAmountFormatted, formatNumberOrString } = useLocalizationContext() if (!option) { @@ -62,7 +62,6 @@ function TokenOptionItemWrapper({ return ( } + if (section.data.length === 0) { + return <> + } + return ( { expect(mocked).toHaveBeenCalledWith(MobileUserPropertyName.TransactionAuthMethod, AuthMethod.FaceId, undefined) expect(mocked).toHaveBeenCalledWith(MobileUserPropertyName.Language, 'English', undefined) expect(mocked).toHaveBeenCalledWith(MobileUserPropertyName.Currency, 'USD', undefined) + expect(mocked).toHaveBeenCalledWith(MobileUserPropertyName.TestnetModeEnabled, false, undefined) - expect(mocked).toHaveBeenCalledTimes(17) + expect(mocked).toHaveBeenCalledTimes(18) }) it('sets user properties without active account', async () => { @@ -172,7 +173,8 @@ describe('TraceUserProperties', () => { expect(mocked).toHaveBeenCalledWith(MobileUserPropertyName.WalletSignerCount, 0, undefined) expect(mocked).toHaveBeenCalledWith(MobileUserPropertyName.AppOpenAuthMethod, AuthMethod.None, undefined) expect(mocked).toHaveBeenCalledWith(MobileUserPropertyName.TransactionAuthMethod, AuthMethod.None, undefined) + expect(mocked).toHaveBeenCalledWith(MobileUserPropertyName.TestnetModeEnabled, false, undefined) - expect(mocked).toHaveBeenCalledTimes(11) + expect(mocked).toHaveBeenCalledTimes(12) }) }) diff --git a/apps/mobile/src/components/Trace/TraceUserProperties.tsx b/apps/mobile/src/components/Trace/TraceUserProperties.tsx index ebe0dad10f2..4648b3629e6 100644 --- a/apps/mobile/src/components/Trace/TraceUserProperties.tsx +++ b/apps/mobile/src/components/Trace/TraceUserProperties.tsx @@ -7,7 +7,11 @@ import { getFullAppVersion } from 'src/utils/version' import { useIsDarkMode } from 'ui/src' import { useAppFiatCurrency } from 'uniswap/src/features/fiatCurrency/hooks' import { useCurrentLanguageInfo } from 'uniswap/src/features/language/hooks' -import { useHideSmallBalancesSetting, useHideSpamTokensSetting } from 'uniswap/src/features/settings/hooks' +import { + useEnabledChains, + useHideSmallBalancesSetting, + useHideSpamTokensSetting, +} from 'uniswap/src/features/settings/hooks' import { MobileUserPropertyName, setUserProperty } from 'uniswap/src/features/telemetry/user' import { isAndroid } from 'utilities/src/platform' import { selectAllowAnalytics } from 'wallet/src/features/telemetry/selectors' @@ -36,6 +40,7 @@ export function TraceUserProperties(): null { const currentFiatCurrency = useAppFiatCurrency() const hideSpamTokens = useHideSpamTokensSetting() const hideSmallBalances = useHideSmallBalancesSetting() + const { isTestnetModeEnabled } = useEnabledChains() // Effects must check this and ensure they are setting properties for when analytics is reenabled const allowAnalytics = useSelector(selectAllowAnalytics) @@ -111,5 +116,9 @@ export function TraceUserProperties(): null { setUserProperty(MobileUserPropertyName.Currency, currentFiatCurrency) }, [allowAnalytics, currentFiatCurrency]) + useEffect(() => { + setUserProperty(MobileUserPropertyName.TestnetModeEnabled, isTestnetModeEnabled) + }, [allowAnalytics, isTestnetModeEnabled]) + return null } diff --git a/apps/mobile/src/components/explore/ExploreSections.tsx b/apps/mobile/src/components/explore/ExploreSections.tsx index f2d02dbcd5a..e8867663abe 100644 --- a/apps/mobile/src/components/explore/ExploreSections.tsx +++ b/apps/mobile/src/components/explore/ExploreSections.tsx @@ -149,7 +149,7 @@ export function ExploreSections({ listRef }: ExploreSectionsProps): JSX.Element visibleHeight={visibleListHeight} /> - + {t('explore.tokens.top.title')} @@ -217,7 +217,7 @@ function NetworkPillsRow({ ) return ( - + { jest.spyOn(unitagHooks, 'useUnitagByAddress').mockReturnValue({ unitag: { username: 'unitagname' }, loading: false, + fetching: false, + pending: false, }) const { queryByText } = render() diff --git a/apps/mobile/src/components/explore/SortButton.tsx b/apps/mobile/src/components/explore/SortButton.tsx index 36089f28edf..76bec02b621 100644 --- a/apps/mobile/src/components/explore/SortButton.tsx +++ b/apps/mobile/src/components/explore/SortButton.tsx @@ -108,16 +108,10 @@ function _SortButton({ orderBy }: FilterGroupProps): JSX.Element { }, [MenuItem, dispatch, menuActions]) return ( - + - + {getTokensOrderBySelectedLabel(orderBy, t)} diff --git a/apps/mobile/src/components/explore/TokenItem.tsx b/apps/mobile/src/components/explore/TokenItem.tsx index 327d5c048b7..e4003213464 100644 --- a/apps/mobile/src/components/explore/TokenItem.tsx +++ b/apps/mobile/src/components/explore/TokenItem.tsx @@ -119,7 +119,7 @@ export const TokenItem = memo(function _TokenItem({ {!hideNumberedList && ( - + {index + 1} diff --git a/apps/mobile/src/components/explore/__snapshots__/SortButton.test.tsx.snap b/apps/mobile/src/components/explore/__snapshots__/SortButton.test.tsx.snap index b602d859049..81c824d1833 100644 --- a/apps/mobile/src/components/explore/__snapshots__/SortButton.test.tsx.snap +++ b/apps/mobile/src/components/explore/__snapshots__/SortButton.test.tsx.snap @@ -39,11 +39,12 @@ exports[`SortButton renders without error 1`] = ` "paddingTop": 8, } } - testID="chain-selector" + testID="dropdown-toggle" > { payload: { name: 'swap-modal', initialState: { - exactAmountToken: '0', + exactAmountToken: '', exactCurrencyField: 'input', [CurrencyField.INPUT]: null, [CurrencyField.OUTPUT]: { diff --git a/apps/mobile/src/components/explore/hooks.ts b/apps/mobile/src/components/explore/hooks.ts index 63f4b1ad5ab..75acba8c9a2 100644 --- a/apps/mobile/src/components/explore/hooks.ts +++ b/apps/mobile/src/components/explore/hooks.ts @@ -59,7 +59,7 @@ export function useExploreTokenContextMenu({ const onPressSwap = useCallback(() => { const swapFormState: TransactionState = { exactCurrencyField: CurrencyField.INPUT, - exactAmountToken: '0', + exactAmountToken: '', [CurrencyField.INPUT]: null, [CurrencyField.OUTPUT]: { chainId, diff --git a/apps/mobile/src/components/explore/search/SearchPopularTokens.tsx b/apps/mobile/src/components/explore/search/SearchPopularTokens.tsx index 996075e3478..27eaf73f468 100644 --- a/apps/mobile/src/components/explore/search/SearchPopularTokens.tsx +++ b/apps/mobile/src/components/explore/search/SearchPopularTokens.tsx @@ -4,9 +4,11 @@ import { FlatList, ListRenderItemInfo } from 'react-native' import { SearchTokenItem } from 'src/components/explore/search/items/SearchTokenItem' import { getSearchResultId } from 'src/components/explore/search/utils' import { Flex, Loader } from 'ui/src' +import { ProtectionResult, SafetyLevel } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' import { ALL_NETWORKS_ARG } from 'uniswap/src/data/rest/base' import { useTokenRankingsQuery } from 'uniswap/src/data/rest/tokenRankings' import { fromGraphQLChain } from 'uniswap/src/features/chains/utils' +import { TokenList } from 'uniswap/src/features/dataApi/types' import { SearchResultType, TokenSearchResult } from 'uniswap/src/features/search/SearchResult' import { UniverseChainId } from 'uniswap/src/types/chains' import { RankingType } from 'wallet/src/features/wallet/types' @@ -32,7 +34,14 @@ function tokenStatsToTokenSearchResult(token: Maybe): TokenSe name, symbol, logoUrl: logo ?? null, - safetyLevel: null, + // BE has confirmed that all of these TokenRankingsStat tokens are Verified SafetyLevel, and design confirmed that we can hide the warning icon here + safetyLevel: SafetyLevel.Verified, + safetyInfo: { + tokenList: TokenList.Default, + attackType: undefined, + protectionResult: ProtectionResult.Benign, + }, + feeData: null, } } diff --git a/apps/mobile/src/components/explore/search/SearchResultsLoader.tsx b/apps/mobile/src/components/explore/search/SearchResultsLoader.tsx index 9886f9af5b8..6782b30e030 100644 --- a/apps/mobile/src/components/explore/search/SearchResultsLoader.tsx +++ b/apps/mobile/src/components/explore/search/SearchResultsLoader.tsx @@ -1,42 +1,32 @@ import React from 'react' -import { useTranslation } from 'react-i18next' -import { FadeIn, FadeOut } from 'react-native-reanimated' +import { NFTHeaderItem, TokenHeaderItem, WalletHeaderItem } from 'src/components/explore/search/constants' import { SectionHeaderText } from 'src/components/explore/search/SearchSectionHeader' +import { SearchHeader } from 'src/components/explore/search/types' import { Flex, Loader } from 'ui/src' -import { Coin, Gallery, Person } from 'ui/src/components/icons' -import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' +import { UniverseChainId } from 'uniswap/src/types/chains' -export const SearchResultsLoader = (): JSX.Element => { - const { t } = useTranslation() +function SectionLoader({ searchHeader, repeat = 1 }: { searchHeader: SearchHeader; repeat?: number }): JSX.Element { return ( - - - } - title={t('explore.search.section.tokens')} - /> - - - - - - } - title={t('explore.search.section.wallets')} - /> - - - - - - } - title={t('explore.search.section.nft')} - /> - - - + + + + ) } + +/** + * Placeholder component used while a search is loading. + */ +export function SearchResultsLoader({ selectedChain }: { selectedChain: UniverseChainId | null }): JSX.Element { + // Only mainnet or "all" networks support nfts, hide loader otherwise + const hideNftLoading = selectedChain !== null && selectedChain !== UniverseChainId.Mainnet + return ( + + + + {!hideNftLoading && } + + ) +} diff --git a/apps/mobile/src/components/explore/search/SearchResultsSection.tsx b/apps/mobile/src/components/explore/search/SearchResultsSection.tsx index 6d7c4cfc5a1..f6bfae5bc3e 100644 --- a/apps/mobile/src/components/explore/search/SearchResultsSection.tsx +++ b/apps/mobile/src/components/explore/search/SearchResultsSection.tsx @@ -4,7 +4,13 @@ import { FlatList, ListRenderItemInfo } from 'react-native' import { FadeIn, FadeOut } from 'react-native-reanimated' import { SearchResultsLoader } from 'src/components/explore/search/SearchResultsLoader' import { SectionHeaderText } from 'src/components/explore/search/SearchSectionHeader' -import { SEARCH_RESULT_HEADER_KEY } from 'src/components/explore/search/constants' +import { + EtherscanHeaderItem, + NFTHeaderItem, + SEARCH_RESULT_HEADER_KEY, + TokenHeaderItem, + WalletHeaderItem, +} from 'src/components/explore/search/constants' import { useWalletSearchResults } from 'src/components/explore/search/hooks' import { SearchENSAddressItem } from 'src/components/explore/search/items/SearchENSAddressItem' import { SearchEtherscanItem } from 'src/components/explore/search/items/SearchEtherscanItem' @@ -14,16 +20,13 @@ import { SearchUnitagItem } from 'src/components/explore/search/items/SearchUnit import { SearchWalletByAddressItem } from 'src/components/explore/search/items/SearchWalletByAddressItem' import { SearchResultOrHeader } from 'src/components/explore/search/types' import { - filterSearchResultsByChainId, formatNFTCollectionSearchResults, formatTokenSearchResults, getSearchResultId, } from 'src/components/explore/search/utils' import { Flex, Text } from 'ui/src' -import { Coin, Gallery, Person } from 'ui/src/components/icons' import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' import { BaseCard } from 'uniswap/src/components/BaseCard/BaseCard' -import { UNIVERSE_CHAIN_INFO } from 'uniswap/src/constants/chains' import { useExploreSearchQuery } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' import { SearchContext } from 'uniswap/src/features/search/SearchContext' import { @@ -32,36 +35,10 @@ import { TokenSearchResult, } from 'uniswap/src/features/search/SearchResult' import { useEnabledChains } from 'uniswap/src/features/settings/hooks' -import i18n from 'uniswap/src/i18n/i18n' import { UniverseChainId } from 'uniswap/src/types/chains' import { getValidAddress } from 'uniswap/src/utils/addresses' import { logger } from 'utilities/src/logger/logger' -const ICON_SIZE = '$icon.24' -const ICON_COLOR = '$neutral2' - -const WalletHeaderItem: SearchResultOrHeader = { - icon: , - type: SEARCH_RESULT_HEADER_KEY, - title: i18n.t('explore.search.section.wallets'), -} -const TokenHeaderItem: SearchResultOrHeader = { - icon: , - type: SEARCH_RESULT_HEADER_KEY, - title: i18n.t('explore.search.section.tokens'), -} -const NFTHeaderItem: SearchResultOrHeader = { - icon: , - type: SEARCH_RESULT_HEADER_KEY, - title: i18n.t('explore.search.section.nft'), -} -const EtherscanHeaderItem: (chainId: UniverseChainId) => SearchResultOrHeader = (chainId: UniverseChainId) => ({ - type: SEARCH_RESULT_HEADER_KEY, - title: i18n.t('explore.search.action.viewEtherscan', { - blockExplorerName: UNIVERSE_CHAIN_INFO[chainId].explorer.name, - }), -}) - const IGNORED_ERRORS = ['Subgraph provider undefined not supported'] export function SearchResultsSection({ @@ -93,13 +70,7 @@ export function SearchResultsSection({ return undefined } - const formattedTokenSearchResults = formatTokenSearchResults(searchResultsData.searchTokens, searchQuery) - - if (!selectedChain) { - return formattedTokenSearchResults - } - - return filterSearchResultsByChainId(formattedTokenSearchResults, selectedChain) + return formatTokenSearchResults(searchResultsData.searchTokens, searchQuery, selectedChain) }, [selectedChain, searchQuery, searchResultsData]) // Search for matching NFT collections @@ -109,13 +80,7 @@ export function SearchResultsSection({ return undefined } - const formattedNftCollectionSearchResults = formatNFTCollectionSearchResults(searchResultsData.nftCollections) - - if (!selectedChain) { - return formattedNftCollectionSearchResults - } - - return filterSearchResultsByChainId(formattedNftCollectionSearchResults, selectedChain) + return formatNFTCollectionSearchResults(searchResultsData.nftCollections, selectedChain) }, [searchResultsData, selectedChain]) // Search for matching wallets @@ -187,7 +152,7 @@ export function SearchResultsSection({ // Don't wait for wallet search results if there are already token search results, do wait for token results if (searchResultsLoading) { - return + return } if (error) { @@ -209,7 +174,7 @@ export function SearchResultsSection({ + }} diff --git a/apps/mobile/src/components/explore/search/constants.tsx b/apps/mobile/src/components/explore/search/constants.tsx index 32bebf8f7ac..600a35b0229 100644 --- a/apps/mobile/src/components/explore/search/constants.tsx +++ b/apps/mobile/src/components/explore/search/constants.tsx @@ -1 +1,32 @@ -export const SEARCH_RESULT_HEADER_KEY = 'header' +import { SearchHeader, SearchHeaderKey } from 'src/components/explore/search/types' +import { Coin, Gallery, Person } from 'ui/src/components/icons' +import { UNIVERSE_CHAIN_INFO } from 'uniswap/src/constants/chains' +import i18n from 'uniswap/src/i18n/i18n' +import { UniverseChainId } from 'uniswap/src/types/chains' + +export const SEARCH_RESULT_HEADER_KEY: SearchHeaderKey = 'header' + +const ICON_SIZE = '$icon.24' +const ICON_COLOR = '$neutral2' + +export const WalletHeaderItem: SearchHeader = { + icon: , + type: SEARCH_RESULT_HEADER_KEY, + title: i18n.t('explore.search.section.wallets'), +} +export const TokenHeaderItem: SearchHeader = { + icon: , + type: SEARCH_RESULT_HEADER_KEY, + title: i18n.t('explore.search.section.tokens'), +} +export const NFTHeaderItem: SearchHeader = { + icon: , + type: SEARCH_RESULT_HEADER_KEY, + title: i18n.t('explore.search.section.nft'), +} +export const EtherscanHeaderItem: (chainId: UniverseChainId) => SearchHeader = (chainId: UniverseChainId) => ({ + type: SEARCH_RESULT_HEADER_KEY, + title: i18n.t('explore.search.action.viewEtherscan', { + blockExplorerName: UNIVERSE_CHAIN_INFO[chainId].explorer.name, + }), +}) diff --git a/apps/mobile/src/components/explore/search/items/SearchTokenItem.tsx b/apps/mobile/src/components/explore/search/items/SearchTokenItem.tsx index 361e7a96139..d339c070153 100644 --- a/apps/mobile/src/components/explore/search/items/SearchTokenItem.tsx +++ b/apps/mobile/src/components/explore/search/items/SearchTokenItem.tsx @@ -7,7 +7,7 @@ import { disableOnPress } from 'src/utils/disableOnPress' import { Flex, ImpactFeedbackStyle, Text, TouchableArea } from 'ui/src' import { TokenLogo } from 'uniswap/src/components/CurrencyLogo/TokenLogo' import WarningIcon from 'uniswap/src/components/warnings/WarningIcon' -import { getWarningIconColorOverride } from 'uniswap/src/components/warnings/utils' +import { getWarningIconColors } from 'uniswap/src/components/warnings/utils' import { SearchContext } from 'uniswap/src/features/search/SearchContext' import { SearchResultType, TokenSearchResult } from 'uniswap/src/features/search/SearchResult' import { addToSearchHistory } from 'uniswap/src/features/search/searchHistorySlice' @@ -28,11 +28,12 @@ export function SearchTokenItem({ token, searchContext }: SearchTokenItemProps): const dispatch = useDispatch() const tokenDetailsNavigation = useTokenDetailsNavigation() - const { chainId, address, name, symbol, logoUrl, safetyLevel, safetyInfo } = token + const { chainId, address, name, symbol, logoUrl, safetyLevel, safetyInfo, feeData } = token const currencyId = address ? buildCurrencyId(chainId, address) : buildNativeCurrencyId(chainId as UniverseChainId) const currencyInfo = useCurrencyInfo(currencyId) const severity = getTokenWarningSeverity(currencyInfo) - const warningIconColor = getWarningIconColorOverride(severity) + // in mobile search, we only show the warning icon if token is >=Medium severity + const { colorSecondary: warningIconColor } = getWarningIconColors(severity) const onPress = (): void => { tokenDetailsNavigation.preload(currencyId) @@ -60,6 +61,7 @@ export function SearchTokenItem({ token, searchContext }: SearchTokenItemProps): logoUrl, safetyLevel, safetyInfo, + feeData, }, }), ) diff --git a/apps/mobile/src/components/explore/search/types.tsx b/apps/mobile/src/components/explore/search/types.tsx index 4666ddecf7c..1f50fddc00f 100644 --- a/apps/mobile/src/components/explore/search/types.tsx +++ b/apps/mobile/src/components/explore/search/types.tsx @@ -1,8 +1,5 @@ -import { SEARCH_RESULT_HEADER_KEY } from 'src/components/explore/search/constants' import { SearchResult } from 'uniswap/src/features/search/SearchResult' -// Header type used to render header text instead of SearchResult item - -export type SearchResultOrHeader = - | SearchResult - | { type: typeof SEARCH_RESULT_HEADER_KEY; title: string; icon?: JSX.Element } +export type SearchHeaderKey = 'header' +export type SearchHeader = { type: SearchHeaderKey; title: string; icon?: JSX.Element } +export type SearchResultOrHeader = SearchResult | SearchHeader diff --git a/apps/mobile/src/components/explore/search/utils.test.ts b/apps/mobile/src/components/explore/search/utils.test.ts index a11d3774eed..9e5498b604d 100644 --- a/apps/mobile/src/components/explore/search/utils.test.ts +++ b/apps/mobile/src/components/explore/search/utils.test.ts @@ -6,6 +6,7 @@ import { } from 'src/components/explore/search/utils' import { Chain, ExploreSearchQuery } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' import { fromGraphQLChain } from 'uniswap/src/features/chains/utils' +import { getCurrencySafetyInfo } from 'uniswap/src/features/dataApi/utils' import { SearchResultType } from 'uniswap/src/features/search/SearchResult' import { amount, ethToken, nftCollection, nftContract, token, tokenMarket } from 'uniswap/src/test/fixtures' import { createArray } from 'uniswap/src/test/utils' @@ -14,14 +15,14 @@ type ExploreSearchResult = NonNullable describe(formatTokenSearchResults, () => { it('returns undefined if there is no data', () => { - expect(formatTokenSearchResults(undefined, '')).toEqual(undefined) + expect(formatTokenSearchResults(undefined, '', null)).toEqual(undefined) }) it('filters out duplicate results', () => { const searchToken = token() const data = createArray(2, () => searchToken) - const result = formatTokenSearchResults(data, '') + const result = formatTokenSearchResults(data, '', null) expect(result).toHaveLength(1) expect(result?.[0]?.address).toEqual(data[0].address) @@ -44,7 +45,7 @@ describe(formatTokenSearchResults, () => { }), ] - const result = formatTokenSearchResults(data, '') + const result = formatTokenSearchResults(data, '', null) // Filters out the first token (both tokens share the same project id) expect(result).toHaveLength(1) @@ -58,7 +59,7 @@ describe(formatTokenSearchResults, () => { token({ name: 'Uniswap' }), ] - const result = formatTokenSearchResults(data, 'uniswap') + const result = formatTokenSearchResults(data, 'uniswap', null) expect(result).toHaveLength(2) expect(result?.[0]?.name).toEqual('Uniswap') @@ -69,7 +70,7 @@ describe(formatTokenSearchResults, () => { const searchToken = token() const data = [searchToken] - const result = formatTokenSearchResults(data, '') + const result = formatTokenSearchResults(data, '', null) expect(result).toHaveLength(1) expect(result?.[0]?.type).toEqual(SearchResultType.Token) @@ -79,6 +80,10 @@ describe(formatTokenSearchResults, () => { expect(result?.[0]?.symbol).toEqual(searchToken.symbol) expect(result?.[0]?.logoUrl).toEqual(searchToken.project?.logoUrl) expect(result?.[0]?.safetyLevel).toEqual(searchToken.project?.safetyLevel) + expect(result?.[0]?.feeData).toEqual(searchToken.feeData) + expect(result?.[0]?.safetyInfo).toEqual( + getCurrencySafetyInfo(searchToken.project?.safetyLevel, searchToken.protectionInfo), + ) }) describe(gqlNFTToNFTCollectionSearchResult, () => { @@ -106,7 +111,7 @@ describe(formatTokenSearchResults, () => { describe(formatNFTCollectionSearchResults, () => { it('returns undefined if there is no data', () => { - expect(formatNFTCollectionSearchResults(undefined)).toEqual(undefined) + expect(formatNFTCollectionSearchResults(undefined, null)).toEqual(undefined) }) it('filters out nfts that cannot be formatted', () => { @@ -115,7 +120,7 @@ describe(formatTokenSearchResults, () => { edges: [...topNFTCollections.map((nft) => ({ node: nft })), { node: nftCollection({ name: undefined }) }], } - const result = formatNFTCollectionSearchResults(nftSearchResult) + const result = formatNFTCollectionSearchResults(nftSearchResult, null) expect(result).toHaveLength(2) expect(result?.[0]?.address).toEqual(topNFTCollections[0].nftContracts[0]?.address) diff --git a/apps/mobile/src/components/explore/search/utils.ts b/apps/mobile/src/components/explore/search/utils.ts index 2d3aaf5a7c3..5ee8c313e1e 100644 --- a/apps/mobile/src/components/explore/search/utils.ts +++ b/apps/mobile/src/components/explore/search/utils.ts @@ -11,21 +11,15 @@ import { import { searchResultId } from 'uniswap/src/features/search/searchHistorySlice' import { UniverseChainId } from 'uniswap/src/types/chains' -const MAX_TOKEN_RESULTS_COUNT = 4 +const MAX_TOKEN_RESULTS_COUNT = 8 type ExploreSearchResult = NonNullable -export function filterSearchResultsByChainId( - tokenSearchResults: Array | undefined, - chainId: UniverseChainId | null, -): Array | undefined { - return tokenSearchResults?.filter((searchResult): boolean => chainId === null || searchResult.chainId === chainId) -} - // Formats the tokens portion of explore search results into sorted array of TokenSearchResult export function formatTokenSearchResults( data: ExploreSearchResult['searchTokens'], searchQuery: string, + selectedChain: UniverseChainId | null, ): TokenSearchResult[] | undefined { if (!data) { return undefined @@ -39,10 +33,13 @@ export function formatTokenSearchResults( return tokensMap } - const { name, chain, address, symbol, project, market, protectionInfo } = token + const { name, chain, address, symbol, project, market, protectionInfo, feeData } = token const chainId = fromGraphQLChain(chain) - if (!chainId || !project) { + const shoulderFilterByChain = !!selectedChain + const chainMismatch = shoulderFilterByChain && selectedChain !== chainId + + if (!chainId || !project || chainMismatch) { return tokensMap } @@ -58,6 +55,7 @@ export function formatTokenSearchResults( logoUrl: logoUrl ?? null, volume1D: market?.volume?.value ?? 0, safetyInfo: getCurrencySafetyInfo(safetyLevel, protectionInfo), + feeData: feeData ?? null, } // For token results that share the same TokenProject id, use the token with highest volume @@ -93,6 +91,7 @@ function isExactTokenSearchResultMatch(searchResult: TokenSearchResult, query: s export function formatNFTCollectionSearchResults( data: ExploreSearchResult['nftCollections'], + selectedChain: UniverseChainId | null, ): NFTCollectionSearchResult[] | undefined { if (!data) { return undefined @@ -100,7 +99,10 @@ export function formatNFTCollectionSearchResults( return data.edges.reduce((accum, { node }) => { const formatted = gqlNFTToNFTCollectionSearchResult(node) - if (formatted) { + + const chainMismatch = selectedChain && formatted && formatted.chainId !== selectedChain + + if (formatted && !chainMismatch) { accum.push(formatted) } return accum diff --git a/apps/mobile/src/components/home/introCards/OnboardingIntroCardStack.tsx b/apps/mobile/src/components/home/introCards/OnboardingIntroCardStack.tsx index 82be3bcb9a0..40c1d08661b 100644 --- a/apps/mobile/src/components/home/introCards/OnboardingIntroCardStack.tsx +++ b/apps/mobile/src/components/home/introCards/OnboardingIntroCardStack.tsx @@ -16,7 +16,12 @@ import { OnboardingCardLoggingName } from 'uniswap/src/features/telemetry/types' import { useTranslation } from 'uniswap/src/i18n' import { ImportType, OnboardingEntryPoint } from 'uniswap/src/types/onboarding' import { MobileScreens, OnboardingScreens, UnitagScreens } from 'uniswap/src/types/screens/mobile' -import { CardType, IntroCardGraphicType, IntroCardProps } from 'wallet/src/components/introCards/IntroCard' +import { + CardType, + IntroCardGraphicType, + IntroCardProps, + isOnboardingCardLoggingName, +} from 'wallet/src/components/introCards/IntroCard' import { INTRO_CARD_MIN_HEIGHT, IntroCardStack } from 'wallet/src/components/introCards/IntroCardStack' import { useSharedIntroCards } from 'wallet/src/components/introCards/useSharedIntroCards' import { selectHasViewedWelcomeWalletCard } from 'wallet/src/features/behaviorHistory/selectors' @@ -175,7 +180,7 @@ export function OnboardingIntroCardStack({ const handleSwiped = useCallback( (_card: IntroCardProps, index: number) => { const loggingName = cards[index]?.loggingName - if (loggingName) { + if (loggingName && isOnboardingCardLoggingName(loggingName)) { sendAnalyticsEvent(WalletEventName.OnboardingIntroCardSwiped, { card_name: loggingName, }) diff --git a/apps/mobile/src/features/deepLinking/handleDeepLinkSaga.ts b/apps/mobile/src/features/deepLinking/handleDeepLinkSaga.ts index 559aafb6185..b16d66a433c 100644 --- a/apps/mobile/src/features/deepLinking/handleDeepLinkSaga.ts +++ b/apps/mobile/src/features/deepLinking/handleDeepLinkSaga.ts @@ -263,6 +263,26 @@ export function* handleDeepLink(action: ReturnType) { return } + if (screen && userAddress) { + const validUserAddress = yield* call(parseAndValidateUserAddress, userAddress) + yield* put(setAccountAsActive(validUserAddress)) + + switch (screen) { + case 'transaction': + if (fiatOnRamp) { + yield* call(handleOnRampReturnLink) + } else { + yield* call(handleTransactionLink) + } + break + case 'swap': + yield* call(handleSwapLink, url) + break + default: + throw new Error('Invalid or unsupported screen') + } + } + // Skip handling any non-WalletConnect uniswap:// URL scheme deep links for now for security reasons // Currently only used on WalletConnect Universal Link web page fallback button (https://uniswap.org/app/wc) if (action.payload.url.startsWith(UNISWAP_URL_SCHEME)) { @@ -295,26 +315,6 @@ export function* handleDeepLink(action: ReturnType) { return } - if (screen && userAddress) { - const validUserAddress = yield* call(parseAndValidateUserAddress, userAddress) - yield* put(setAccountAsActive(validUserAddress)) - - switch (screen) { - case 'transaction': - if (fiatOnRamp) { - yield* call(handleOnRampReturnLink) - } else { - yield* call(handleTransactionLink) - } - break - case 'swap': - yield* call(handleSwapLink, url) - break - default: - throw new Error('Invalid or unsupported screen') - } - } - if (url.hostname === UNISWAP_WEB_HOSTNAME) { const urlParts = url.href.split(`${UNISWAP_WEB_HOSTNAME}/`) const urlPath = urlParts.length >= 1 ? (urlParts[1] as string) : '' diff --git a/apps/mobile/src/features/externalProfile/ProfileHeader.tsx b/apps/mobile/src/features/externalProfile/ProfileHeader.tsx index c95e731f550..37b0177ca7d 100644 --- a/apps/mobile/src/features/externalProfile/ProfileHeader.tsx +++ b/apps/mobile/src/features/externalProfile/ProfileHeader.tsx @@ -24,10 +24,11 @@ import { import { ENS_LOGO } from 'ui/src/assets' import { SendAction, XTwitter } from 'ui/src/components/icons' import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' -import { DEP_accentColors, iconSizes, imageSizes, validColor } from 'ui/src/theme' +import { DEP_accentColors, iconSizes, imageSizes, spacing, validColor } from 'ui/src/theme' import { useAvatar } from 'uniswap/src/features/address/avatar' import { useENSDescription, useENSName, useENSTwitterUsername } from 'uniswap/src/features/ens/api' import { selectWatchedAddressSet } from 'uniswap/src/features/favorites/selectors' +import { useTestnetModeBannerHeight } from 'uniswap/src/features/settings/hooks' import { ModalName } from 'uniswap/src/features/telemetry/constants' import { TestID } from 'uniswap/src/test/fixtures/testIDs' import { CurrencyField } from 'uniswap/src/types/currency' @@ -40,6 +41,8 @@ import { DisplayNameType } from 'wallet/src/features/wallet/types' const HEADER_GRADIENT_HEIGHT = 144 const HEADER_ICON_SIZE = 72 +// prevents buttons from touching banner +const TESTNET_BANNER_MULTIPLIER = 1.1 interface ProfileHeaderProps { address: Address @@ -119,14 +122,16 @@ export const ProfileHeader = memo(function ProfileHeader({ address }: ProfileHea const { t } = useTranslation() + const testnetBannerHeight = useTestnetModeBannerHeight() * TESTNET_BANNER_MULTIPLIER + return ( - + {/* fixed gradient at 0.2 opacity overlaid on surface1 */} { if (event.url.startsWith('uniswap://openai')) { const capturedPhrase = decodeURI(event.url.split('uniswap://openai?capturedPhrase=')[1] ?? '') - capturedPhrase && sendMessage(capturedPhrase).catch(console.error) + capturedPhrase && + sendMessage(capturedPhrase).catch((e) => + logger.error(e, { tags: { file: 'OpenAIContext', function: 'siriListener' } }), + ) } }) return listener.remove diff --git a/apps/mobile/src/features/send/SendTokenForm.tsx b/apps/mobile/src/features/send/SendTokenForm.tsx index 0d6767bf40a..cc796b42a2a 100644 --- a/apps/mobile/src/features/send/SendTokenForm.tsx +++ b/apps/mobile/src/features/send/SendTokenForm.tsx @@ -2,7 +2,7 @@ import { useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { StyleSheet } from 'react-native' -import { Flex, Text, TouchableArea, useIsShortMobileDevice, useSporeColors } from 'ui/src' +import { Flex, Text, TouchableArea, useSporeColors } from 'ui/src' import InfoCircleFilled from 'ui/src/assets/icons/info-circle-filled.svg' import { AlertCircle } from 'ui/src/components/icons' import { useDeviceDimensions } from 'ui/src/hooks/useDeviceDimensions' @@ -16,6 +16,7 @@ import { MAX_FIAT_INPUT_DECIMALS } from 'uniswap/src/constants/transactions' import { ModalName } from 'uniswap/src/features/telemetry/constants' import { DecimalPadCalculateSpace, + DecimalPadCalculatedSpaceId, DecimalPadInput, DecimalPadInputRef, } from 'uniswap/src/features/transactions/DecimalPadInput/DecimalPadInput' @@ -42,7 +43,6 @@ const TRANSFER_DIRECTION_BUTTON_BORDER_WIDTH = spacing.spacing4 export function SendTokenForm(): JSX.Element { const { t } = useTranslation() const colors = useSporeColors() - const isShortMobileDevice = useIsShortMobileDevice() const { fullHeight } = useDeviceDimensions() const { walletNeedsRestore, openWalletRestoreModal } = useTransactionModalContext() @@ -220,8 +220,6 @@ export function SendTokenForm(): JSX.Element { maxDecimals, }) - console.log('truncatedValue in decimal set value', truncatedValue) - if (isFiatInput) { exactAmountFiatRef.current = truncatedValue } else { @@ -382,9 +380,11 @@ export function SendTokenForm(): JSX.Element { ) : null} + {!nftIn && ( <> - + + | undefined { switch (screen) { case MobileScreens.SettingsWallet: diff --git a/apps/mobile/src/features/unitags/EditUnitagProfileScreen.tsx b/apps/mobile/src/features/unitags/EditUnitagProfileScreen.tsx index 764e5b5bb10..71d27b360aa 100644 --- a/apps/mobile/src/features/unitags/EditUnitagProfileScreen.tsx +++ b/apps/mobile/src/features/unitags/EditUnitagProfileScreen.tsx @@ -93,14 +93,14 @@ export function EditUnitagProfileScreen({ route }: UnitagStackScreenProp {showDeleteUnitagModal && ( - + )} {showChangeUnitagModal && ( )} diff --git a/apps/mobile/src/screens/DevScreen.tsx b/apps/mobile/src/screens/DevScreen.tsx index e8664029021..d953af6729f 100644 --- a/apps/mobile/src/screens/DevScreen.tsx +++ b/apps/mobile/src/screens/DevScreen.tsx @@ -1,6 +1,5 @@ import React, { useState } from 'react' import { Alert, I18nManager, ScrollView } from 'react-native' -import { getUniqueId } from 'react-native-device-info' import { useDispatch, useSelector } from 'react-redux' import { navigate } from 'src/app/navigation/rootNavigation' import { BackButton } from 'src/components/buttons/BackButton' @@ -14,6 +13,7 @@ import { resetDismissedWarnings } from 'uniswap/src/features/tokens/slice/slice' import { useAppInsets } from 'uniswap/src/hooks/useAppInsets' import { MobileScreens } from 'uniswap/src/types/screens/mobile' import { setClipboard } from 'uniswap/src/utils/clipboard' +import { getUniqueId } from 'utilities/src/device/getUniqueId' import { logger } from 'utilities/src/logger/logger' import { useAsyncData } from 'utilities/src/react/hooks' import { UniconSampleSheet } from 'wallet/src/components/DevelopmentOnly/UniconSampleSheet' diff --git a/apps/mobile/src/screens/ExchangeTransferConnecting.tsx b/apps/mobile/src/screens/ExchangeTransferConnecting.tsx index acdef2a3e5e..753c67c9060 100644 --- a/apps/mobile/src/screens/ExchangeTransferConnecting.tsx +++ b/apps/mobile/src/screens/ExchangeTransferConnecting.tsx @@ -66,7 +66,7 @@ export function ExchangeTransferConnecting({ serviceProvider: serviceProvider.serviceProvider, walletAddress: activeAccountAddress, externalSessionId: externalTransactionId, - redirectUrl: `${uniswapUrls.redirectUrlBase}/?screen=transaction&fiatOnRamp=true&userAddress=${activeAccountAddress}`, + redirectUrl: `${uniswapUrls.redirectUrlBase}?screen=transaction&fiatOnRamp=true&userAddress=${activeAccountAddress}`, }) useEffect(() => { diff --git a/apps/mobile/src/screens/FiatOnRampConnecting.tsx b/apps/mobile/src/screens/FiatOnRampConnecting.tsx index 409aad387c5..b9bb136dd6b 100644 --- a/apps/mobile/src/screens/FiatOnRampConnecting.tsx +++ b/apps/mobile/src/screens/FiatOnRampConnecting.tsx @@ -83,7 +83,7 @@ export function FiatOnRampConnectingScreen({ navigation }: Props): JSX.Element | sourceCurrencyCode: baseCurrencyInfo.code, walletAddress: activeAccountAddress, externalSessionId: externalTransactionId, - redirectUrl: `${uniswapUrls.redirectUrlBase}/?screen=transaction&fiatOnRamp=true&userAddress=${activeAccountAddress}`, + redirectUrl: `${uniswapUrls.redirectUrlBase}?screen=transaction&fiatOnRamp=true&userAddress=${activeAccountAddress}`, } : skipToken, ) diff --git a/apps/mobile/src/screens/FiatOnRampScreen.tsx b/apps/mobile/src/screens/FiatOnRampScreen.tsx index 037e46a96bb..ce06dc78a4d 100644 --- a/apps/mobile/src/screens/FiatOnRampScreen.tsx +++ b/apps/mobile/src/screens/FiatOnRampScreen.tsx @@ -54,6 +54,7 @@ import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import { FORAmountEnteredProperties } from 'uniswap/src/features/telemetry/types' import { DecimalPadCalculateSpace, + DecimalPadCalculatedSpaceId, DecimalPadInput, DecimalPadInputRef, } from 'uniswap/src/features/transactions/DecimalPadInput/DecimalPadInput' @@ -329,6 +330,8 @@ export function FiatOnRampScreen({ navigation }: Props): JSX.Element { setValue('') setAmount(0) + valueRef.current = '' + resetSelection({ start: 0 }) setQuoteCurrency(defaultCurrency) sendAnalyticsEvent(FiatOffRampEventName.FORBuySellToggled, { @@ -399,7 +402,9 @@ export function FiatOnRampScreen({ navigation }: Props): JSX.Element { setShowTokenSelector(true) }} /> - + + + { - if (!selectedRecoveryWalletInfos.find((walletInfo) => walletInfo.address === address)) { + if (!selectedRecoveryWalletInfos.find((walletInfo) => areAddressesEqual(walletInfo.address, address))) { return Keyring.removePrivateKey(address) } return Promise.resolve() diff --git a/apps/mobile/src/screens/Import/RestoreCloudBackupScreen.tsx b/apps/mobile/src/screens/Import/RestoreCloudBackupScreen.tsx index e92bda0d2b4..b6f5cd41afe 100644 --- a/apps/mobile/src/screens/Import/RestoreCloudBackupScreen.tsx +++ b/apps/mobile/src/screens/Import/RestoreCloudBackupScreen.tsx @@ -65,7 +65,7 @@ export function RestoreCloudBackupScreen({ navigation, route: { params } }: Prop {sanitizeAddressText(shortenAddress(mnemonicId))} - + {localizedDayjs.unix(createdAt).format(FORMAT_DATE_TIME_SHORT)} diff --git a/apps/mobile/src/screens/NFTCollectionScreen.tsx b/apps/mobile/src/screens/NFTCollectionScreen.tsx index 4058439078e..2eaf948b322 100644 --- a/apps/mobile/src/screens/NFTCollectionScreen.tsx +++ b/apps/mobile/src/screens/NFTCollectionScreen.tsx @@ -172,14 +172,13 @@ export function NFTCollectionScreen({ > {item.listPrice && ( { @@ -225,11 +229,11 @@ function NFTItemScreenContents({ > - ) : ( + ) : displayCollectionName ? ( - {name} + {displayCollectionName} - ) + ) : undefined } renderedInModal={inModal} rightElement={rightElement} @@ -259,15 +263,22 @@ function NFTItemScreenContents({ /> ) : ( - - > => refetch?.()} - /> + + {displayCollectionName ? ( + + {displayCollectionName} + + ) : ( + > => refetch?.()} + /> + )} )} + {nftLoading ? ( - ) : name ? ( + ) : displayCollectionName ? ( - {name} + {displayCollectionName} ) : null} setConfirmContinueButtonEnabled(true)} /> - + + + {showSpeedBumpModal && ( @@ -297,12 +300,16 @@ function ManualBackWarningModal({ onBack, onContinue }: ManualBackWarningModalPr - - + + + + + + diff --git a/apps/mobile/src/screens/Onboarding/NotificationsSetupScreen.tsx b/apps/mobile/src/screens/Onboarding/NotificationsSetupScreen.tsx index f5a2ae1e1ea..dd26e1b5dac 100644 --- a/apps/mobile/src/screens/Onboarding/NotificationsSetupScreen.tsx +++ b/apps/mobile/src/screens/Onboarding/NotificationsSetupScreen.tsx @@ -16,7 +16,6 @@ import { ElementName } from 'uniswap/src/features/telemetry/constants' import i18n from 'uniswap/src/i18n/i18n' import { OnboardingEntryPoint } from 'uniswap/src/types/onboarding' import { OnboardingScreens } from 'uniswap/src/types/screens/mobile' -import { isIOS } from 'utilities/src/platform' import { useOnboardingContext } from 'wallet/src/features/onboarding/OnboardingContext' import { useNativeAccountExists } from 'wallet/src/features/wallet/hooks' import { openSettings } from 'wallet/src/utils/linking' @@ -74,7 +73,7 @@ export function NotificationsSetupScreen({ navigation, route: { params } }: Prop title={t('onboarding.notification.title')} onSkip={navigateToNextScreen} > - + diff --git a/apps/mobile/src/screens/Onboarding/SecuritySetupScreen.tsx b/apps/mobile/src/screens/Onboarding/SecuritySetupScreen.tsx index ade7ec10153..edd7588f5d8 100644 --- a/apps/mobile/src/screens/Onboarding/SecuritySetupScreen.tsx +++ b/apps/mobile/src/screens/Onboarding/SecuritySetupScreen.tsx @@ -1,5 +1,4 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack' -import { BlurView } from 'expo-blur' import React, { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { ActivityIndicator, Alert, Image, Platform, StyleSheet } from 'react-native' @@ -18,10 +17,7 @@ import { OnboardingScreen } from 'src/features/onboarding/OnboardingScreen' import { useCompleteOnboardingCallback } from 'src/features/onboarding/hooks' import { Button, Flex, useIsDarkMode, useSporeColors } from 'ui/src' import { SECURITY_SCREEN_BACKGROUND_DARK, SECURITY_SCREEN_BACKGROUND_LIGHT } from 'ui/src/assets' -import FaceIcon from 'ui/src/assets/icons/faceid-thin.svg' -import FingerprintIcon from 'ui/src/assets/icons/fingerprint.svg' import { Lock } from 'ui/src/components/icons' -import { borderRadii, imageSizes, opacify } from 'ui/src/theme' import Trace from 'uniswap/src/features/telemetry/Trace' import { ElementName } from 'uniswap/src/features/telemetry/constants' import { ImportType } from 'uniswap/src/types/onboarding' @@ -35,7 +31,6 @@ export function SecuritySetupScreen({ route: { params } }: Props): JSX.Element { const { t } = useTranslation() const colors = useSporeColors() const dispatch = useDispatch() - const isDarkMode = useIsDarkMode() const [isLoadingAccount, setIsLoadingAccount] = useState(false) const [showWarningModal, setShowWarningModal] = useState(false) @@ -119,27 +114,10 @@ export function SecuritySetupScreen({ route: { params } }: Props): JSX.Element { title={t('onboarding.security.title')} onSkip={onSkipPressed} > - - + + - - - {isTouchIdDevice ? ( - - ) : ( - - )} - ) : ( <> - + - { - setSearchValue(event.target.value) + placeholderTextColor="$neutral3" + onChangeText={(value) => { + if (value === '.') { + setSearchValue('0.') + return + } + // Prevent two decimals + if (value.indexOf('.') !== -1 && value.indexOf('.', value.indexOf('.') + 1) !== -1) { + return + } + // Prevent addition of non-numeric characters to the end of the string + if (!numericInputRegex.test(value)) { + setSearchValue(value.slice(0, -1)) + return + } + + const newValue = parseFloat(value) + if (newValue > 100) { + setSearchValue('100') + return + } + + setSearchValue(newValue >= 0 ? value : '') }} - value={searchValue} /> - - {/* TODO(WEB-4920): filter fee tiers based on search term */} - {feeTiers.map((feeTier) => ( - { - setPositionState((prevState) => ({ ...prevState, fee: feeTier })) - onClose() - }} - > - - {formatPercent(new Percent(feeTier, 1000000))} - - {/* TODO(WEB-4920): use real data from positions API */} - - $289.6K TVL - - - {t('fee.tier.percent.select', { percentage: 4 })} - + + {Object.values(feeTierData) + .filter((data) => data.formattedFee.includes(searchValue) || searchValue.includes(data.id)) + .map((pool) => ( + { + setPositionState((prevState) => ({ + ...prevState, + fee: { + feeAmount: pool.fee, + tickSpacing: calculateTickSpacingFromFeeAmount(pool.fee), + }, + })) + onClose() + }} + > + + {pool.formattedFee} + + + {formatNumberOrString({ input: pool.totalLiquidityUsd, type: NumberType.ChartFiatValue })} + + + {t('fee.tier.percent.select', { percentage: formatPercent(pool.percentage) })} + + + {pool.fee === selectedFee.feeAmount && } - {feeTier === selectedFee && } - - ))} + ))} diff --git a/apps/web/src/components/Liquidity/HookModal.tsx b/apps/web/src/components/Liquidity/HookModal.tsx new file mode 100644 index 00000000000..03a164d166b --- /dev/null +++ b/apps/web/src/components/Liquidity/HookModal.tsx @@ -0,0 +1,183 @@ +import { FlagWarning, getFlagsFromContractAddress, getFlagWarning } from 'components/Liquidity/utils' +import { GetHelpHeader } from 'components/Modal/GetHelpHeader' +import { useCreatePositionContext } from 'pages/Pool/Positions/create/CreatePositionContext' +import { useMemo, useState } from 'react' +import { CopyHelper } from 'theme/components' +import { Button, Checkbox, Flex, HeightAnimator, Separator, Text, TouchableArea } from 'ui/src' +import { RotatableChevron } from 'ui/src/components/icons/RotatableChevron' +import { Modal } from 'uniswap/src/components/modals/Modal' +import { LearnMoreLink } from 'uniswap/src/components/text/LearnMoreLink' +import { uniswapUrls } from 'uniswap/src/constants/urls' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { useTranslation } from 'uniswap/src/i18n' +import { shortenAddress } from 'uniswap/src/utils/addresses' + +function HookWarnings({ flags }: { flags: FlagWarning[] }) { + const { t } = useTranslation() + + const [expandedProperties, setExpandedProperties] = useState(false) + + const toggleExpandedProperties = () => { + setExpandedProperties((state) => !state) + } + + if (!flags.length) { + return null + } + + return ( + <> + + + + + + {t('position.addingHook.viewProperties')} + + + + + + {expandedProperties && ( + + {flags.map(({ name, info, dangerous }) => ( + + + + {name} + + + + + {info} + + + + ))} + + )} + + ) +} + +export function HookModal({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) { + const { t } = useTranslation() + const [disclaimerChecked, setDisclaimerChecked] = useState(false) + + const { + setPositionState, + positionState: { hook }, + } = useCreatePositionContext() + + const clearHook = () => { + setPositionState((state) => ({ + ...state, + hook: undefined, + })) + onClose() + } + + const onContinue = () => { + if (disclaimerChecked) { + onClose() + } + } + + const onDisclaimerChecked = () => { + setDisclaimerChecked((state) => !state) + } + + const { flags, hasDangerous } = useMemo(() => { + if (!hook) { + return { + flags: [], + hasDangerous: false, + } + } + + let hasDangerous = false + const flagInfos: Record = {} + getFlagsFromContractAddress(hook).forEach((flag) => { + const warning = getFlagWarning(flag, t) + + if (warning?.dangerous) { + hasDangerous = true + } + + if (warning?.name) { + flagInfos[warning.name] = warning + } + }) + + return { + flags: Object.values(flagInfos), + hasDangerous, + } + }, [hook, t]) + + if (!hook) { + return null + } + + // TODO(WEB-5289): match entrance/exit animations with the currency selector + return ( + + + + + + {hasDangerous ? t('position.hook.warningHeader') : t('position.addingHook')} + + + {hasDangerous ? t('position.hook.warningInfo') : t('position.addingHook.disclaimer')} + + + + + + + + + + {t('common.text.contract')} + + + + + {shortenAddress(hook)} + + + + + + + {hasDangerous && ( + + + + {t('position.hook.disclaimer')} + + + )} + + + + + + + + + ) +} diff --git a/apps/web/src/components/Liquidity/LiquidityPositionAmountsTile.tsx b/apps/web/src/components/Liquidity/LiquidityPositionAmountsTile.tsx index 2b994469b3f..6164863c8d5 100644 --- a/apps/web/src/components/Liquidity/LiquidityPositionAmountsTile.tsx +++ b/apps/web/src/components/Liquidity/LiquidityPositionAmountsTile.tsx @@ -40,11 +40,9 @@ export function LiquidityPositionAmountsTile({ )} {totalFiatValue?.greaterThan(0) && fiatValue0 && ( - - - {formatPercent(new Percent(fiatValue0.quotient, totalFiatValue.quotient).toFixed(6))} - - + + {formatPercent(new Percent(fiatValue0.quotient, totalFiatValue.quotient).toFixed(6))} + )} @@ -65,11 +63,9 @@ export function LiquidityPositionAmountsTile({ )} {totalFiatValue?.greaterThan(0) && fiatValue1 && ( - - - {formatPercent(new Percent(fiatValue1.quotient, totalFiatValue.quotient).toFixed(6))} - - + + {formatPercent(new Percent(fiatValue1.quotient, totalFiatValue.quotient).toFixed(6))} + )} diff --git a/apps/web/src/components/Liquidity/LiquidityPositionCard.tsx b/apps/web/src/components/Liquidity/LiquidityPositionCard.tsx index bd85679d0f3..22719a86cd0 100644 --- a/apps/web/src/components/Liquidity/LiquidityPositionCard.tsx +++ b/apps/web/src/components/Liquidity/LiquidityPositionCard.tsx @@ -1,8 +1,9 @@ // eslint-disable-next-line no-restricted-imports +import { PositionStatus } from '@uniswap/client-pools/dist/pools/v1/types_pb' import { LiquidityPositionFeeStats } from 'components/Liquidity/LiquidityPositionFeeStats' import { LiquidityPositionInfo } from 'components/Liquidity/LiquidityPositionInfo' +import { useV3OrV4PositionDerivedInfo } from 'components/Liquidity/hooks' import { PositionInfo } from 'components/Liquidity/types' -import { useV3OrV4PositionDerivedInfo } from 'components/Liquidity/utils' import { Flex, FlexProps } from 'ui/src' import { useLocalizationContext } from 'uniswap/src/features/language/LocalizationContext' import { useUSDCValue } from 'uniswap/src/features/transactions/swap/hooks/useUSDCPrice' @@ -20,7 +21,7 @@ export function LiquidityPositionCard({ liquidityPosition, ...rest }: { liquidit fiatValue0 && fiatValue1 ? formatCurrencyAmount({ value: fiatValue0.add(fiatValue1), - type: NumberType.FiatTokenPrice, + type: NumberType.FiatStandard, }) : undefined const v2FormattedUsdValue = @@ -32,7 +33,8 @@ export function LiquidityPositionCard({ liquidityPosition, ...rest }: { liquidit fiatFeeValue0 && fiatFeeValue1 ? formatCurrencyAmount({ value: fiatFeeValue0.add(fiatFeeValue1), - type: NumberType.FiatTokenPrice, + type: + liquidityPosition.status === PositionStatus.CLOSED ? NumberType.FiatStandard : NumberType.FiatTokenPrice, }) : undefined diff --git a/apps/web/src/components/Liquidity/LiquidityPositionFeeStats.tsx b/apps/web/src/components/Liquidity/LiquidityPositionFeeStats.tsx index 83f817fdc49..f8f719e0cd1 100644 --- a/apps/web/src/components/Liquidity/LiquidityPositionFeeStats.tsx +++ b/apps/web/src/components/Liquidity/LiquidityPositionFeeStats.tsx @@ -1,10 +1,11 @@ -import { useGetRangeDisplay } from 'components/Liquidity/utils' +import { useGetRangeDisplay } from 'components/Liquidity/hooks' import { PriceOrdering } from 'components/PositionListItem' +import { MouseoverTooltip } from 'components/Tooltip' import { useState } from 'react' import { ClickableTamaguiStyle } from 'theme/components' import { Flex, Text, styled } from 'ui/src' -import { ReverseArrows } from 'ui/src/components/icons/ReverseArrows' -import { Trans } from 'uniswap/src/i18n' +import { ArrowUpDown } from 'ui/src/components/icons/ArrowUpDown' +import { Trans, useTranslation } from 'uniswap/src/i18n' interface LiquidityPositionFeeStatsProps { formattedUsdValue?: string @@ -31,14 +32,13 @@ const SecondaryText = styled(Text, { export function LiquidityPositionFeeStats({ formattedUsdValue, formattedUsdFees, - totalApr, - feeApr, priceOrdering, tickLower, tickUpper, feeTier, showReverseButton = true, }: LiquidityPositionFeeStatsProps) { + const { t } = useTranslation() const [pricesInverted, setPricesInverted] = useState(false) const { maxPrice, minPrice, tokenASymbol, tokenBSymbol } = useGetRangeDisplay({ @@ -50,60 +50,65 @@ export function LiquidityPositionFeeStats({ }) return ( - - {formattedUsdValue && ( - + + + {formattedUsdValue ? ( {formattedUsdValue} - {formattedUsdFees && +{formattedUsdFees} fees} - - )} - {totalApr && ( - - {totalApr} - - - - - )} - {feeApr && ( - - {feeApr} - - - - - )} - {priceOrdering.priceLower && priceOrdering.priceUpper && ( - <> - - - - - - - {minPrice} {tokenASymbol} / {tokenBSymbol} - - - - - - - - {maxPrice} {tokenASymbol} / {tokenBSymbol} - + ) : ( + } placement="top"> + - + + )} + + {t('pool.position')} + + + + {formattedUsdFees || t('common.unavailable')} + + {t('common.fees')} + + + {/* TODO: add APR once its been calculated. */} + + {priceOrdering.priceLower && priceOrdering.priceUpper ? ( + <> + + + + + + + {minPrice} {tokenASymbol} / {tokenBSymbol} + + + + + + + + {maxPrice} {tokenASymbol} / {tokenBSymbol} + + + {showReverseButton && ( + setPricesInverted((prevInverted) => !prevInverted)} + > + + + )} + + ) : ( + + + {t('common.fullRange')} + - {showReverseButton && ( - setPricesInverted((prevInverted) => !prevInverted)} - > - - - )} - - )} + )} + ) } diff --git a/apps/web/src/components/Liquidity/LiquidityPositionInfo.tsx b/apps/web/src/components/Liquidity/LiquidityPositionInfo.tsx index 989664d5ae2..391dee9fbe4 100644 --- a/apps/web/src/components/Liquidity/LiquidityPositionInfo.tsx +++ b/apps/web/src/components/Liquidity/LiquidityPositionInfo.tsx @@ -1,10 +1,9 @@ -import { BadgeData, LiquidityPositionInfoBadges } from 'components/Liquidity/LiquidityPositionInfoBadges' +import { LiquidityPositionInfoBadges } from 'components/Liquidity/LiquidityPositionInfoBadges' import { LiquidityPositionStatusIndicator } from 'components/Liquidity/LiquidityPositionStatusIndicator' import { PositionInfo } from 'components/Liquidity/types' import { getProtocolVersionLabel } from 'components/Liquidity/utils' import { DoubleCurrencyAndChainLogo } from 'components/Logo/DoubleLogo' import { Flex, Text } from 'ui/src' -import { DocumentList } from 'ui/src/components/icons/DocumentList' interface LiquidityPositionInfoProps { positionInfo: PositionInfo @@ -26,18 +25,7 @@ export function LiquidityPositionInfo({ positionInfo }: LiquidityPositionInfoPro {currency0Amount?.currency.symbol} / {currency1Amount?.currency.symbol} - } - : undefined, - feeTier ? { label: `${Number(feeTier) / 10000}%` } : undefined, - ].filter(Boolean) as BadgeData[] - } - /> + diff --git a/apps/web/src/components/Liquidity/LiquidityPositionInfoBadges.test.tsx b/apps/web/src/components/Liquidity/LiquidityPositionInfoBadges.test.tsx index 178b9c00a05..242b04981bd 100644 --- a/apps/web/src/components/Liquidity/LiquidityPositionInfoBadges.test.tsx +++ b/apps/web/src/components/Liquidity/LiquidityPositionInfoBadges.test.tsx @@ -1,22 +1,20 @@ import { LiquidityPositionInfoBadges } from 'components/Liquidity/LiquidityPositionInfoBadges' import { render } from 'test-utils/render' -const testBadgeData = [{ label: 'test', copyable: true }, { label: 'test2' }] - describe('LiquidityPositionInfoBadges', () => { it('should render with default size', () => { - const { getByText } = render() - expect(getByText('test')).toBeInTheDocument() + const { getByText } = render() + expect(getByText('2')).toBeInTheDocument() }) it('should render with small size', () => { - const { getByText } = render() - expect(getByText('test')).toBeInTheDocument() + const { getByText } = render() + expect(getByText('2')).toBeInTheDocument() }) it('should render with multiple badges', () => { - const { getByText } = render() - expect(getByText('test')).toBeInTheDocument() - expect(getByText('test2')).toBeInTheDocument() + const { getByText } = render() + expect(getByText('2')).toBeInTheDocument() + expect(getByText('0.01%')).toBeInTheDocument() }) }) diff --git a/apps/web/src/components/Liquidity/LiquidityPositionInfoBadges.tsx b/apps/web/src/components/Liquidity/LiquidityPositionInfoBadges.tsx index 1f8258f977c..dd08a38adc0 100644 --- a/apps/web/src/components/Liquidity/LiquidityPositionInfoBadges.tsx +++ b/apps/web/src/components/Liquidity/LiquidityPositionInfoBadges.tsx @@ -1,5 +1,9 @@ +import { FeeAmount } from '@uniswap/v3-sdk' +import { ZERO_ADDRESS } from 'constants/misc' +import { useMemo } from 'react' import { CopyHelper } from 'theme/components' import { styled, Text } from 'ui/src' +import { DocumentList } from 'ui/src/components/icons/DocumentList' import { isAddress, shortenAddress } from 'utilities/src/addresses' export const PositionInfoBadge = styled(Text, { @@ -42,19 +46,33 @@ function getPlacement(index: number, length: number): 'start' | 'middle' | 'end' return length === 1 ? 'only' : index === 0 ? 'start' : index === length - 1 ? 'end' : 'middle' } -export interface BadgeData { +interface BadgeData { label: string copyable?: boolean icon?: JSX.Element } export function LiquidityPositionInfoBadges({ - badges, + versionLabel, + v4hook, + feeTier, size = 'default', }: { - badges: BadgeData[] + versionLabel?: string + v4hook?: string + feeTier?: string | FeeAmount size: 'small' | 'default' }): JSX.Element { + const badges = useMemo(() => { + return [ + versionLabel ? { label: versionLabel } : undefined, + v4hook && v4hook !== ZERO_ADDRESS + ? { label: v4hook, copyable: true, icon: } + : undefined, + feeTier ? { label: `${Number(feeTier) / 10000}%` } : undefined, + ].filter(Boolean) as BadgeData[] + }, [versionLabel, v4hook, feeTier]) + return ( <> {badges.map(({ label, copyable, icon }, index) => { diff --git a/apps/web/src/components/Liquidity/LiquidityPositionPriceRangeTile.tsx b/apps/web/src/components/Liquidity/LiquidityPositionPriceRangeTile.tsx index d99f15c7441..7f02e079e3c 100644 --- a/apps/web/src/components/Liquidity/LiquidityPositionPriceRangeTile.tsx +++ b/apps/web/src/components/Liquidity/LiquidityPositionPriceRangeTile.tsx @@ -1,8 +1,6 @@ // eslint-disable-next-line no-restricted-imports -import { PositionStatus } from '@uniswap/client-pools/dist/pools/v1/types_pb' import { Currency, Price } from '@uniswap/sdk-core' -import { LiquidityPositionStatusIndicator } from 'components/Liquidity/LiquidityPositionStatusIndicator' -import { useGetRangeDisplay } from 'components/Liquidity/utils' +import { useGetRangeDisplay } from 'components/Liquidity/hooks' import { PriceOrdering } from 'components/PositionListItem' import { useMemo, useState } from 'react' import { Flex, SegmentedControl, SegmentedControlOption, Text, styled } from 'ui/src' @@ -19,7 +17,6 @@ const InnerTile = styled(Flex, { }) interface LiquidityPositionPriceRangeTileProps { - status?: PositionStatus priceOrdering: PriceOrdering token0CurrentPrice: Price token1CurrentPrice: Price @@ -29,7 +26,6 @@ interface LiquidityPositionPriceRangeTileProps { } export function LiquidityPositionPriceRangeTile({ - status, priceOrdering, token0CurrentPrice, token1CurrentPrice, @@ -77,7 +73,6 @@ export function LiquidityPositionPriceRangeTile({ - {status && } - + diff --git a/apps/web/src/components/Liquidity/hooks.ts b/apps/web/src/components/Liquidity/hooks.ts new file mode 100644 index 00000000000..db2f6db3739 --- /dev/null +++ b/apps/web/src/components/Liquidity/hooks.ts @@ -0,0 +1,257 @@ +import { BigNumber } from '@ethersproject/bignumber' +// eslint-disable-next-line no-restricted-imports +import { ProtocolVersion } from '@uniswap/client-pools/dist/pools/v1/types_pb' +import { Currency, CurrencyAmount, Percent, Price } from '@uniswap/sdk-core' +import { PositionInfo } from 'components/Liquidity/types' +import { calculateInvertedValues, parseV3FeeTier } from 'components/Liquidity/utils' +import { PriceOrdering, getPriceOrderingFromPositionForUI } from 'components/PositionListItem' +import useIsTickAtLimit from 'hooks/useIsTickAtLimit' +import JSBI from 'jsbi' +import { OptionalCurrency } from 'pages/Pool/Positions/create/types' +import { getCurrencyAddressWithWrap, getSortedCurrenciesTuple } from 'pages/Pool/Positions/create/utils' +import { useMemo } from 'react' +import { useAppSelector } from 'state/hooks' +import { Bound } from 'state/mint/v3/actions' +import { useGetPoolsByTokens } from 'uniswap/src/data/rest/getPools' +import { useUSDCPrice } from 'uniswap/src/features/transactions/swap/hooks/useUSDCPrice' +import { NumberType, useFormatter } from 'utils/formatNumbers' + +type FeeTierData = { + id: string + fee: number + formattedFee: string + totalLiquidityUsd: number + percentage: Percent +} + +/** + * @returns map of fee tier (in hundredths of bips) to more data about the Pool + * + */ +export function useAllFeeTierPoolData({ + chainId, + protocolVersion, + currencies, +}: { + chainId?: number + protocolVersion: ProtocolVersion + currencies: [OptionalCurrency, OptionalCurrency] +}): Record { + const { formatPercent } = useFormatter() + const sortedCurrencies = getSortedCurrenciesTuple(currencies[0], currencies[1]) + const token0Address = getCurrencyAddressWithWrap(sortedCurrencies[0], protocolVersion) + const token1Address = getCurrencyAddressWithWrap(sortedCurrencies[1], protocolVersion) + const { data: poolData } = useGetPoolsByTokens( + { + chainId, + protocolVersions: [protocolVersion], + token0: token0Address, + token1: token1Address, + }, + Boolean(chainId && sortedCurrencies?.[0] && sortedCurrencies?.[1]), + ) + return useMemo(() => { + const liquiditySum = poolData?.pools.reduce( + (sum, pool) => BigNumber.from(pool.totalLiquidityUsd.split('.')?.[0] ?? '0').add(sum), + BigNumber.from(0), + ) + + const feeTierData: Record = {} + if (poolData && liquiditySum && sortedCurrencies?.[0] && sortedCurrencies?.[1]) { + for (const pool of poolData.pools) { + const feeTier = pool.fee + const totalLiquidityUsdTruncated = Number(pool.totalLiquidityUsd.split('.')?.[0] ?? '0') + const percentage = liquiditySum.isZero() + ? new Percent(0, 100) + : new Percent(totalLiquidityUsdTruncated, liquiditySum.toString()) + if (feeTierData[feeTier]) { + feeTierData[feeTier].totalLiquidityUsd += totalLiquidityUsdTruncated + feeTierData[feeTier].percentage = feeTierData[feeTier].percentage.add(percentage) + } else { + feeTierData[feeTier] = { + id: pool.poolId, + fee: pool.fee, + formattedFee: formatPercent(new Percent(pool.fee, 1000000)), + totalLiquidityUsd: totalLiquidityUsdTruncated, + percentage, + } + } + } + } + return feeTierData + }, [poolData, sortedCurrencies, formatPercent]) +} + +/** + * V3-specific hooks for a position parsed using parseRestPosition. + */ +export function useV3OrV4PositionDerivedInfo(positionInfo?: PositionInfo) { + const { + token0UncollectedFees, + token1UncollectedFees, + currency0Amount, + currency1Amount, + liquidity, + tickLower, + tickUpper, + } = positionInfo ?? {} + const { price: price0 } = useUSDCPrice(currency0Amount?.currency) + const { price: price1 } = useUSDCPrice(currency1Amount?.currency) + + const { feeValue0, feeValue1 } = useMemo(() => { + if (!currency0Amount || !currency1Amount) { + return {} + } + return { + feeValue0: token0UncollectedFees + ? CurrencyAmount.fromRawAmount(currency0Amount.currency, token0UncollectedFees) + : undefined, + feeValue1: token1UncollectedFees + ? CurrencyAmount.fromRawAmount(currency1Amount.currency, token1UncollectedFees) + : undefined, + } + }, [currency0Amount, currency1Amount, token0UncollectedFees, token1UncollectedFees]) + + const { fiatFeeValue0, fiatFeeValue1 } = useMemo(() => { + const amount0 = feeValue0 ? price0?.quote(feeValue0) : undefined + const amount1 = feeValue1 ? price1?.quote(feeValue1) : undefined + return { + fiatFeeValue0: amount0, + fiatFeeValue1: amount1, + } + }, [price0, price1, feeValue0, feeValue1]) + + const { fiatValue0, fiatValue1 } = useMemo(() => { + if (!price0 || !price1 || !currency0Amount || !currency1Amount) { + return {} + } + const amount0 = price0.quote(currency0Amount) + const amount1 = price1.quote(currency1Amount) + return { + fiatValue0: amount0, + fiatValue1: amount1, + } + }, [price0, price1, currency0Amount, currency1Amount]) + + const priceOrdering = useMemo(() => { + if ( + (positionInfo?.version !== ProtocolVersion.V3 && positionInfo?.version !== ProtocolVersion.V4) || + !positionInfo.position || + !liquidity || + !tickLower || + !tickUpper + ) { + return {} + } + return getPriceOrderingFromPositionForUI(positionInfo.position) + }, [liquidity, tickLower, tickUpper, positionInfo]) + + return useMemo( + () => ({ + fiatFeeValue0, + fiatFeeValue1, + fiatValue0, + fiatValue1, + priceOrdering, + feeValue0, + feeValue1, + token0CurrentPrice: + positionInfo?.version === ProtocolVersion.V3 || positionInfo?.version === ProtocolVersion.V4 + ? positionInfo.pool?.token0Price + : undefined, + token1CurrentPrice: + positionInfo?.version === ProtocolVersion.V3 || positionInfo?.version === ProtocolVersion.V4 + ? positionInfo.pool?.token1Price + : undefined, + }), + [fiatFeeValue0, fiatFeeValue1, fiatValue0, fiatValue1, priceOrdering, feeValue0, feeValue1, positionInfo], + ) +} + +export function useGetRangeDisplay({ + token0CurrentPrice, + token1CurrentPrice, + priceOrdering, + pricesInverted, + feeTier, + tickLower, + tickUpper, +}: { + token0CurrentPrice?: Price + token1CurrentPrice?: Price + priceOrdering: PriceOrdering + feeTier?: string + tickLower?: string + tickUpper?: string + pricesInverted: boolean +}): { + currentPrice?: Price + minPrice: string + maxPrice: string + tokenASymbol?: string + tokenBSymbol?: string +} { + const { formatTickPrice } = useFormatter() + + const { currentPrice, priceLower, priceUpper, base, quote } = calculateInvertedValues({ + token0CurrentPrice, + token1CurrentPrice, + ...priceOrdering, + invert: pricesInverted, + }) + + const isTickAtLimit = useIsTickAtLimit(parseV3FeeTier(feeTier), Number(tickLower), Number(tickUpper)) + + const minPrice = formatTickPrice({ + price: priceLower, + atLimit: isTickAtLimit, + direction: Bound.LOWER, + numberType: NumberType.TokenTx, + }) + const maxPrice = formatTickPrice({ + price: priceUpper, + atLimit: isTickAtLimit, + direction: Bound.UPPER, + numberType: NumberType.TokenTx, + }) + const tokenASymbol = quote?.symbol + const tokenBSymbol = base?.symbol + + return { + currentPrice, + minPrice, + maxPrice, + tokenASymbol, + tokenBSymbol, + } +} + +export function usePositionCurrentPrice(positionInfo?: PositionInfo) { + return useMemo(() => { + if (positionInfo?.version === ProtocolVersion.V2) { + return positionInfo.pair?.token1Price + } + + return positionInfo?.pool?.token1Price + }, [positionInfo]) +} + +/** + * Parses the Positions API object from the modal state and returns the relevant information for the modals. + */ +export function useModalLiquidityPositionInfo(): PositionInfo | undefined { + const modalState = useAppSelector((state) => state.application.openModal) + return modalState?.initialState +} + +export function useGetPoolTokenPercentage(positionInfo?: PositionInfo) { + const { totalSupply, liquidityAmount } = positionInfo ?? {} + + const poolTokenPercentage = useMemo(() => { + return !!liquidityAmount && !!totalSupply && JSBI.greaterThanOrEqual(totalSupply.quotient, liquidityAmount.quotient) + ? new Percent(liquidityAmount.quotient, totalSupply.quotient) + : undefined + }, [liquidityAmount, totalSupply]) + + return poolTokenPercentage +} diff --git a/apps/web/src/components/Liquidity/types.ts b/apps/web/src/components/Liquidity/types.ts index 42be9893709..c10cc38726e 100644 --- a/apps/web/src/components/Liquidity/types.ts +++ b/apps/web/src/components/Liquidity/types.ts @@ -51,10 +51,11 @@ type V2PairInfo = BasePositionInfo & { v4hook: undefined } -type V3PositionInfo = BasePositionInfo & { +export type V3PositionInfo = BasePositionInfo & { version: ProtocolVersion.V3 tokenId: string pool?: V3Pool + poolId?: string feeTier?: FeeAmount position?: V3Position v4hook: undefined @@ -64,6 +65,7 @@ type V4PositionInfo = BasePositionInfo & { version: ProtocolVersion.V4 tokenId: string pool?: V4Pool + poolId?: string position?: V4Position feeTier?: string v4hook?: string diff --git a/apps/web/src/components/Liquidity/utils.test.ts b/apps/web/src/components/Liquidity/utils.test.ts new file mode 100644 index 00000000000..4c1f5817fc8 --- /dev/null +++ b/apps/web/src/components/Liquidity/utils.test.ts @@ -0,0 +1,66 @@ +import { HookFlag, getFlagsFromContractAddress } from 'components/Liquidity/utils' + +describe('getFlagsFromContractAddress', () => { + it('should return an empty array for an address with no flags', () => { + const address = '0x1234567890123456789012345678901234560000' + expect(getFlagsFromContractAddress(address)).toEqual([]) + }) + + it('should correctly identify a single flag', () => { + const address = '0x1234567890123456789012345678901234560200' + expect(getFlagsFromContractAddress(address)).toEqual([HookFlag.BeforeRemoveLiquidity]) + }) + + it('should correctly identify multiple flags', () => { + const address = '0x1234567890123456789012345678901234567FFF' + expect(getFlagsFromContractAddress(address)).toEqual([ + HookFlag.BeforeRemoveLiquidity, + HookFlag.AfterRemoveLiquidity, + HookFlag.BeforeAddLiquidity, + HookFlag.AfterAddLiquidity, + HookFlag.BeforeSwap, + HookFlag.AfterSwap, + HookFlag.BeforeDonate, + HookFlag.AfterDonate, + HookFlag.BeforeSwapReturnsDelta, + HookFlag.AfterSwapReturnsDelta, + HookFlag.AfterAddLiquidityReturnsDelta, + HookFlag.AfterRemoveLiquidityReturnsDelta, + ]) + }) + + it('should correctly identify a mix of flags (case 1)', () => { + const address = '0x123456789012345678901234567890123456789A' + expect(getFlagsFromContractAddress(address)).toEqual([ + HookFlag.BeforeAddLiquidity, + HookFlag.BeforeSwap, + HookFlag.AfterDonate, + HookFlag.BeforeSwapReturnsDelta, + HookFlag.AfterAddLiquidityReturnsDelta, + ]) + }) + + it('should correctly identify a mix of flags (case 2)', () => { + const address = '0x12345678901234567890123456789012345678C0' + expect(getFlagsFromContractAddress(address)).toEqual([ + HookFlag.BeforeAddLiquidity, + HookFlag.BeforeSwap, + HookFlag.AfterSwap, + ]) + }) + + it('should correctly identify a mix of flags (case 3)', () => { + const address = '0x123456789012345678901234567890123456780B' + expect(getFlagsFromContractAddress(address)).toEqual([ + HookFlag.BeforeAddLiquidity, + HookFlag.BeforeSwapReturnsDelta, + HookFlag.AfterAddLiquidityReturnsDelta, + HookFlag.AfterRemoveLiquidityReturnsDelta, + ]) + }) + + it('should correctly identify a mix of flags (case 4)', () => { + const address = '0x0000000000000000000000000000000000002400' + expect(getFlagsFromContractAddress(address)).toEqual([HookFlag.AfterAddLiquidity]) + }) +}) diff --git a/apps/web/src/components/Liquidity/utils.tsx b/apps/web/src/components/Liquidity/utils.tsx index fea80b09942..cebd917d15d 100644 --- a/apps/web/src/components/Liquidity/utils.tsx +++ b/apps/web/src/components/Liquidity/utils.tsx @@ -9,23 +9,15 @@ import { Position as RestPosition, Token as RestToken, } from '@uniswap/client-pools/dist/pools/v1/types_pb' -import { Currency, CurrencyAmount, Percent, Price, Token } from '@uniswap/sdk-core' +import { Currency, CurrencyAmount, Price, Token } from '@uniswap/sdk-core' import { Pair } from '@uniswap/v2-sdk' import { FeeAmount, Pool as V3Pool, Position as V3Position } from '@uniswap/v3-sdk' import { Pool as V4Pool, Position as V4Position } from '@uniswap/v4-sdk' import { PositionInfo } from 'components/Liquidity/types' -import { PriceOrdering, getPriceOrderingFromPositionForUI } from 'components/PositionListItem' import { ZERO_ADDRESS } from 'constants/misc' -import useIsTickAtLimit from 'hooks/useIsTickAtLimit' -import JSBI from 'jsbi' -import { useMemo } from 'react' -import { useAppSelector } from 'state/hooks' -import { Bound } from 'state/mint/v3/actions' import { AppTFunction } from 'ui/src/i18n/types' import { nativeOnChain } from 'uniswap/src/constants/tokens' import { ProtocolItems } from 'uniswap/src/data/tradingApi/__generated__' -import { useUSDCPrice } from 'uniswap/src/features/transactions/swap/hooks/useUSDCPrice' -import { NumberType, useFormatter } from 'utils/formatNumbers' export function getProtocolVersionLabel(version: ProtocolVersion): string | undefined { switch (version) { @@ -133,7 +125,7 @@ export function getPoolFromRest({ if (pool instanceof RestPool) { if (protocolVersion === ProtocolVersion.V3) { - return new V3Pool(token0, token1, pool.fee, pool.sqrtPriceX96, pool.liquidity, pool.tick) + return new V3Pool(token0 as Token, token1 as Token, pool.fee, pool.sqrtPriceX96, pool.liquidity, pool.tick) } return new V4Pool( @@ -141,7 +133,7 @@ export function getPoolFromRest({ token1, pool.fee, pool.tickSpacing, - hooks || '', + hooks || ZERO_ADDRESS, pool.sqrtPriceX96, pool.liquidity, pool.tick, @@ -152,16 +144,24 @@ export function getPoolFromRest({ if (protocolVersion === ProtocolVersion.V3) { const feeTier = parseV3FeeTier(pool.feeTier) if (feeTier) { - return new V3Pool(token0, token1, feeTier, pool.currentPrice, pool.liquidity, parseInt(pool.currentTick)) + return new V3Pool( + token0 as Token, + token1 as Token, + feeTier, + pool.currentPrice, + pool.currentLiquidity, + parseInt(pool.currentTick), + ) } } + const fee = parseInt(pool.feeTier ?? '') return new V4Pool( token0, token1, - parseInt(pool.feeTier), + fee, parseInt(pool.tickSpacing), - hooks || '', + hooks || ZERO_ADDRESS, pool.currentPrice, pool.liquidity, parseInt(pool.currentTick), @@ -254,6 +254,7 @@ export function parseRestPosition(position?: RestPosition): PositionInfo | undef feeTier: parseV3FeeTier(v3Position.feeTier), version: ProtocolVersion.V3, pool, + poolId: position.position.value.poolId, position: sdkPosition, tickLower: v3Position.tickLower, tickUpper: v3Position.tickUpper, @@ -285,20 +286,21 @@ export function parseRestPosition(position?: RestPosition): PositionInfo | undef tickUpper: Number(v4Position.tickUpper), }) : undefined - + const poolId = V4Pool.getPoolId(token0, token1, Number(v4Position.feeTier), Number(v4Position.tickSpacing), hook) return { status: position.status, - feeTier: v4Position?.feeTier, + feeTier: v4Position.feeTier, version: ProtocolVersion.V4, position: sdkPosition, pool, + poolId, v4hook: hook, tokenId: v4Position.tokenId, - tickLower: v4Position?.tickLower, - tickUpper: v4Position?.tickUpper, - tickSpacing: Number(v4Position?.tickSpacing), - currency0Amount: CurrencyAmount.fromRawAmount(token0, v4Position?.amount0 ?? 0), - currency1Amount: CurrencyAmount.fromRawAmount(token1, v4Position?.amount1 ?? 0), + tickLower: v4Position.tickLower, + tickUpper: v4Position.tickUpper, + tickSpacing: Number(v4Position.tickSpacing), + currency0Amount: CurrencyAmount.fromRawAmount(token0, v4Position.amount0 ?? 0), + currency1Amount: CurrencyAmount.fromRawAmount(token1, v4Position.amount1 ?? 0), token0UncollectedFees: v4Position.token0UncollectedFees, token1UncollectedFees: v4Position.token1UncollectedFees, liquidity: v4Position.liquidity, @@ -308,171 +310,7 @@ export function parseRestPosition(position?: RestPosition): PositionInfo | undef } } -/** - * Parses the Positions API object from the modal state and returns the relevant information for the modals. - */ -export function useModalLiquidityPositionInfo(): PositionInfo | undefined { - const modalState = useAppSelector((state) => state.application.openModal) - return modalState?.initialState -} - -export function useGetPoolTokenPercentage(positionInfo?: PositionInfo) { - const { totalSupply, liquidityAmount } = positionInfo ?? {} - - const poolTokenPercentage = useMemo(() => { - return !!liquidityAmount && !!totalSupply && JSBI.greaterThanOrEqual(totalSupply.quotient, liquidityAmount.quotient) - ? new Percent(liquidityAmount.quotient, totalSupply.quotient) - : undefined - }, [liquidityAmount, totalSupply]) - - return poolTokenPercentage -} - -/** - * V3-specific hooks for a position parsed using parseRestPosition. - */ -export function useV3OrV4PositionDerivedInfo(positionInfo?: PositionInfo) { - const { - token0UncollectedFees, - token1UncollectedFees, - currency0Amount, - currency1Amount, - liquidity, - tickLower, - tickUpper, - } = positionInfo ?? {} - const { price: price0 } = useUSDCPrice(currency0Amount?.currency) - const { price: price1 } = useUSDCPrice(currency1Amount?.currency) - - const { feeValue0, feeValue1 } = useMemo(() => { - if (!currency0Amount || !currency1Amount) { - return {} - } - return { - feeValue0: token0UncollectedFees - ? CurrencyAmount.fromRawAmount(currency0Amount.currency, token0UncollectedFees) - : undefined, - feeValue1: token1UncollectedFees - ? CurrencyAmount.fromRawAmount(currency1Amount.currency, token1UncollectedFees) - : undefined, - } - }, [currency0Amount, currency1Amount, token0UncollectedFees, token1UncollectedFees]) - - const { fiatFeeValue0, fiatFeeValue1 } = useMemo(() => { - const amount0 = feeValue0 ? price0?.quote(feeValue0) : undefined - const amount1 = feeValue1 ? price1?.quote(feeValue1) : undefined - return { - fiatFeeValue0: amount0, - fiatFeeValue1: amount1, - } - }, [price0, price1, feeValue0, feeValue1]) - - const { fiatValue0, fiatValue1 } = useMemo(() => { - if (!price0 || !price1 || !currency0Amount || !currency1Amount) { - return {} - } - const amount0 = price0.quote(currency0Amount) - const amount1 = price1.quote(currency1Amount) - return { - fiatValue0: amount0, - fiatValue1: amount1, - } - }, [price0, price1, currency0Amount, currency1Amount]) - - const priceOrdering = useMemo(() => { - if ( - (positionInfo?.version !== ProtocolVersion.V3 && positionInfo?.version !== ProtocolVersion.V4) || - !positionInfo.position || - !liquidity || - !tickLower || - !tickUpper - ) { - return {} - } - return getPriceOrderingFromPositionForUI(positionInfo.position) - }, [liquidity, tickLower, tickUpper, positionInfo]) - - return useMemo( - () => ({ - fiatFeeValue0, - fiatFeeValue1, - fiatValue0, - fiatValue1, - priceOrdering, - feeValue0, - feeValue1, - token0CurrentPrice: - positionInfo?.version === ProtocolVersion.V3 || positionInfo?.version === ProtocolVersion.V4 - ? positionInfo.pool?.token0Price - : undefined, - token1CurrentPrice: - positionInfo?.version === ProtocolVersion.V3 || positionInfo?.version === ProtocolVersion.V4 - ? positionInfo.pool?.token1Price - : undefined, - }), - [fiatFeeValue0, fiatFeeValue1, fiatValue0, fiatValue1, priceOrdering, feeValue0, feeValue1, positionInfo], - ) -} - -export function useGetRangeDisplay({ - token0CurrentPrice, - token1CurrentPrice, - priceOrdering, - pricesInverted, - feeTier, - tickLower, - tickUpper, -}: { - token0CurrentPrice?: Price - token1CurrentPrice?: Price - priceOrdering: PriceOrdering - feeTier?: string - tickLower?: string - tickUpper?: string - pricesInverted: boolean -}): { - currentPrice?: Price - minPrice: string - maxPrice: string - tokenASymbol?: string - tokenBSymbol?: string -} { - const { formatTickPrice } = useFormatter() - - const { currentPrice, priceLower, priceUpper, base, quote } = calculateInvertedValues({ - token0CurrentPrice, - token1CurrentPrice, - ...priceOrdering, - invert: pricesInverted, - }) - - const isTickAtLimit = useIsTickAtLimit(parseV3FeeTier(feeTier), Number(tickLower), Number(tickUpper)) - - const minPrice = formatTickPrice({ - price: priceLower, - atLimit: isTickAtLimit, - direction: Bound.LOWER, - numberType: NumberType.TokenTx, - }) - const maxPrice = formatTickPrice({ - price: priceUpper, - atLimit: isTickAtLimit, - direction: Bound.UPPER, - numberType: NumberType.TokenTx, - }) - const tokenASymbol = quote?.symbol - const tokenBSymbol = base?.symbol - - return { - currentPrice, - minPrice, - maxPrice, - tokenASymbol, - tokenBSymbol, - } -} - -function calculateInvertedValues({ +export function calculateInvertedValues({ token0CurrentPrice, token1CurrentPrice, priceLower, @@ -503,3 +341,100 @@ function calculateInvertedValues({ base: invert ? quote : base, } } + +export function calculateTickSpacingFromFeeAmount(feeAmount: number): number { + return (2 * feeAmount) / 100 +} + +export function calculateInvertedPrice({ price, invert }: { price?: Price; invert: boolean }) { + const currentPrice = invert ? price?.invert() : price + + return { + price: currentPrice, + quote: currentPrice?.quoteCurrency, + base: currentPrice?.baseCurrency, + } +} + +export enum HookFlag { + BeforeAddLiquidity = 'before-add-liquidity', + AfterAddLiquidity = 'after-add-liquidity', + BeforeRemoveLiquidity = 'before-remove-liquidity', + AfterRemoveLiquidity = 'after-remove-liquidity', + BeforeSwap = 'before-swap', + AfterSwap = 'after-swap', + BeforeDonate = 'before-donate', + AfterDonate = 'after-donate', + BeforeSwapReturnsDelta = 'before-swap-returns-delta', + AfterSwapReturnsDelta = 'after-swap-returns-delta', + AfterAddLiquidityReturnsDelta = 'after-add-liquidity-returns-delta', + AfterRemoveLiquidityReturnsDelta = 'after-remove-liquidity-returns-delta', +} + +// The flags are ordered with the dangerous ones on top so they are rendered first +const FLAGS: { [key in HookFlag]: number } = { + [HookFlag.BeforeRemoveLiquidity]: 1 << 9, + [HookFlag.AfterRemoveLiquidity]: 1 << 8, + [HookFlag.BeforeAddLiquidity]: 1 << 11, + [HookFlag.AfterAddLiquidity]: 1 << 10, + [HookFlag.BeforeSwap]: 1 << 7, + [HookFlag.AfterSwap]: 1 << 6, + [HookFlag.BeforeDonate]: 1 << 5, + [HookFlag.AfterDonate]: 1 << 4, + [HookFlag.BeforeSwapReturnsDelta]: 1 << 3, + [HookFlag.AfterSwapReturnsDelta]: 1 << 2, + [HookFlag.AfterAddLiquidityReturnsDelta]: 1 << 1, + [HookFlag.AfterRemoveLiquidityReturnsDelta]: 1 << 0, +} + +export function getFlagsFromContractAddress(contractAddress: Address): HookFlag[] { + // Extract the last 4 hexadecimal digits from the address + const last4Hex = contractAddress.slice(-4) + + // Convert the hex string to a binary string + const binaryStr = parseInt(last4Hex, 16).toString(2) + + // Parse the last 12 bits of the binary string + const relevantBits = binaryStr.slice(-12) + + // Determine which flags are active + const activeFlags = Object.entries(FLAGS) + .filter(([, bitPosition]) => (parseInt(relevantBits, 2) & bitPosition) !== 0) + .map(([flag]) => flag as HookFlag) + + return activeFlags +} + +export interface FlagWarning { + name: string + info: string + dangerous: boolean +} + +export function getFlagWarning(flag: HookFlag, t: AppTFunction): FlagWarning | undefined { + switch (flag) { + case HookFlag.BeforeSwap: + case HookFlag.BeforeSwapReturnsDelta: + return { + name: t('common.swap'), + info: t('position.hook.swapWarning'), + dangerous: false, + } + case HookFlag.BeforeAddLiquidity: + case HookFlag.AfterAddLiquidity: + return { + name: t('common.addLiquidity'), + info: t('position.hook.liquidityWarning'), + dangerous: false, + } + case HookFlag.BeforeRemoveLiquidity: + case HookFlag.AfterRemoveLiquidity: + return { + name: t('pool.removeLiquidity'), + info: t('position.hook.removeWarning'), + dangerous: true, + } + } + + return undefined +} diff --git a/apps/web/src/components/LiquidityChartRangeInput/index.tsx b/apps/web/src/components/LiquidityChartRangeInput/index.tsx index 283897fc3c9..c767386cd05 100644 --- a/apps/web/src/components/LiquidityChartRangeInput/index.tsx +++ b/apps/web/src/components/LiquidityChartRangeInput/index.tsx @@ -1,4 +1,4 @@ -import { Currency, Price, Token } from '@uniswap/sdk-core' +import { Currency, Price } from '@uniswap/sdk-core' import { FeeAmount } from '@uniswap/v3-sdk' import { AutoColumn, ColumnCenter } from 'components/deprecated/Column' import Loader from 'components/Icons/LoadingSpinner' @@ -80,8 +80,8 @@ export default function LiquidityChartRangeInput({ feeAmount?: FeeAmount ticksAtLimit: { [bound in Bound]?: boolean | undefined } price?: number - priceLower?: Price - priceUpper?: Price + priceLower?: Price + priceUpper?: Price onLeftRangeInput: (typedValue: string) => void onRightRangeInput: (typedValue: string) => void interactive: boolean diff --git a/apps/web/src/components/Logo/QueryTokenLogo.tsx b/apps/web/src/components/Logo/QueryTokenLogo.tsx index e244d129fb0..70bbe941f83 100644 --- a/apps/web/src/components/Logo/QueryTokenLogo.tsx +++ b/apps/web/src/components/Logo/QueryTokenLogo.tsx @@ -4,7 +4,7 @@ import { getChainFromChainUrlParam } from 'constants/chains' import { NATIVE_CHAIN_ID } from 'constants/tokens' import { GqlSearchToken } from 'graphql/data/SearchTokens' import { TokenQueryData } from 'graphql/data/Token' -import { TopToken } from 'graphql/data/TopTokens' +import { TopToken } from 'graphql/data/types' import { gqlToCurrency } from 'graphql/data/util' import useNativeCurrency from 'lib/hooks/useNativeCurrency' import { useMemo } from 'react' diff --git a/apps/web/src/components/NavBar/ChainSelector/__snapshots__/ChainSelectorRow.test.tsx.snap b/apps/web/src/components/NavBar/ChainSelector/__snapshots__/ChainSelectorRow.test.tsx.snap index d6427200e1c..fbf6e7ea95b 100644 --- a/apps/web/src/components/NavBar/ChainSelector/__snapshots__/ChainSelectorRow.test.tsx.snap +++ b/apps/web/src/components/NavBar/ChainSelector/__snapshots__/ChainSelectorRow.test.tsx.snap @@ -498,6 +498,13 @@ exports[`ChainSelectorRow should match snapshot for chainId 480 1`] = ` } .c1 { + grid-column: 2; + grid-row: 1; + font-size: 16px; + font-weight: 485; +} + +.c2 { grid-column: 3; grid-row: 1; display: -webkit-box; @@ -524,10 +531,23 @@ exports[`ChainSelectorRow should match snapshot for chainId 480 1`] = ` > diff --git a/apps/web/src/components/NavBar/SearchBar/SuggestionRow.tsx b/apps/web/src/components/NavBar/SearchBar/SuggestionRow.tsx index c268316741c..fb1d0d651a0 100644 --- a/apps/web/src/components/NavBar/SearchBar/SuggestionRow.tsx +++ b/apps/web/src/components/NavBar/SearchBar/SuggestionRow.tsx @@ -7,6 +7,7 @@ import Column from 'components/deprecated/Column' import { useTokenWarning } from 'constants/deprecatedTokenSafety' import { NATIVE_CHAIN_ID } from 'constants/tokens' import { GqlSearchToken } from 'graphql/data/SearchTokens' +import { gqlTokenToCurrencyInfo } from 'graphql/data/types' import { getTokenDetailsURL, supportedChainIdFromGQLChain } from 'graphql/data/util' import styled, { css } from 'lib/styled-components' import { searchGenieCollectionToTokenSearchResult, searchTokenToTokenSearchResult } from 'lib/utils/searchBar' @@ -17,10 +18,14 @@ import { Link, useNavigate } from 'react-router-dom' import { EllipsisStyle, ThemedText } from 'theme/components' import { Flex } from 'ui/src' import { Verified } from 'ui/src/components/icons/Verified' -import { TokenStandard } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' +import WarningIcon from 'uniswap/src/components/warnings/WarningIcon' +import { Token, TokenStandard } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' +import { FeatureFlags } from 'uniswap/src/features/gating/flags' +import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { addToSearchHistory } from 'uniswap/src/features/search/searchHistorySlice' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import { InterfaceSearchResultSelectionProperties } from 'uniswap/src/features/telemetry/types' +import { getTokenWarningSeverity } from 'uniswap/src/features/tokens/safetyUtils' import { Trans, useTranslation } from 'uniswap/src/i18n' import { UniverseChainId } from 'uniswap/src/types/chains' import { shortenAddress } from 'uniswap/src/utils/addresses' @@ -105,10 +110,15 @@ export function SuggestionRow({ const navigate = useNavigate() const { formatFiatPrice, formatDelta, formatNumberOrString } = useFormatter() const [brokenCollectionImage, setBrokenCollectionImage] = useState(false) + + const tokenProtectionEnabled = useFeatureFlag(FeatureFlags.TokenProtection) const warning = useTokenWarning( isToken ? suggestion.address : undefined, isToken ? supportedChainIdFromGQLChain(suggestion.chain) : UniverseChainId.Mainnet, ) + const tokenWarningSeverity = isToken + ? getTokenWarningSeverity(gqlTokenToCurrencyInfo(suggestion as Token)) // casting GqlSearchToken to Token + : undefined const handleClick = useCallback(() => { const address = @@ -180,9 +190,23 @@ export function SuggestionRow({ /> )} - + {suggestion.name} - {isToken ? : suggestion.isVerified && } + {isToken ? ( + tokenProtectionEnabled ? ( + + ) : ( + + ) + ) : ( + suggestion.isVerified && + )} diff --git a/apps/web/src/components/NavBar/Tabs/TabsContent.tsx b/apps/web/src/components/NavBar/Tabs/TabsContent.tsx index 0877059c208..8f49d2fbaba 100644 --- a/apps/web/src/components/NavBar/Tabs/TabsContent.tsx +++ b/apps/web/src/components/NavBar/Tabs/TabsContent.tsx @@ -25,7 +25,7 @@ export type TabsItem = MenuItem & { export const useTabsContent = (props?: { includeNftsLink?: boolean }): TabsSection[] => { const { t } = useTranslation() - const isMultichainExploreEnabled = useFeatureFlag(FeatureFlags.MultichainExplore) + const isV4EverywhereEnabled = useFeatureFlag(FeatureFlags.V4Everywhere) const { pathname } = useLocation() const theme = useTheme() const areTabsVisible = useTabsVisible() @@ -76,7 +76,7 @@ export const useTabsContent = (props?: { includeNftsLink?: boolean }): TabsSecti { label: t('common.transactions'), quickKey: 'X', - href: `/explore/transactions${isMultichainExploreEnabled ? '/ethereum' : ''}`, + href: '/explore/transactions/ethereum', internal: true, }, { label: t('common.nfts'), quickKey: 'N', href: '/nfts', internal: true }, @@ -84,14 +84,19 @@ export const useTabsContent = (props?: { includeNftsLink?: boolean }): TabsSecti }, { title: t('common.pool'), - href: '/pool', + href: isV4EverywhereEnabled ? '/positions' : '/pool', isActive: pathname.startsWith('/pool'), items: [ - { label: t('nav.tabs.viewPosition'), quickKey: 'V', href: '/pool', internal: true }, + { + label: t('nav.tabs.viewPosition'), + quickKey: 'V', + href: isV4EverywhereEnabled ? '/positions' : '/pool', + internal: true, + }, { label: t('nav.tabs.createPosition'), quickKey: 'V', - href: '/add', + href: isV4EverywhereEnabled ? '/positions/create' : '/add', internal: true, }, ], diff --git a/apps/web/src/components/NavBar/index.tsx b/apps/web/src/components/NavBar/index.tsx index 7d3396bd814..6c6430df9d4 100644 --- a/apps/web/src/components/NavBar/index.tsx +++ b/apps/web/src/components/NavBar/index.tsx @@ -23,8 +23,6 @@ import { useProfilePageState } from 'nft/hooks' import { ProfilePageStateType } from 'nft/types' import { BREAKPOINTS } from 'theme' import { Z_INDEX } from 'theme/zIndex' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlagWithLoading } from 'uniswap/src/features/gating/hooks' import { useEnabledChains } from 'uniswap/src/features/settings/hooks' import { INTERFACE_NAV_HEIGHT } from 'uniswap/src/theme/heights' @@ -78,20 +76,12 @@ function useShouldHideChainSelector() { const isSwapPage = useIsSwapPage() const isLimitPage = useIsLimitPage() const isExplorePage = useIsExplorePage() - const { value: multichainExploreFlagEnabled, isLoading: isMultichainExploreFlagLoading } = useFeatureFlagWithLoading( - FeatureFlags.MultichainExplore, - ) const baseHiddenPages = isNftPage - const multichainHiddenPages = isLandingPage || isSendPage || isSwapPage || isLimitPage || baseHiddenPages - const multichainExploreHiddenPages = multichainHiddenPages || isExplorePage - - const hideChainSelector = - multichainExploreFlagEnabled || isMultichainExploreFlagLoading - ? multichainExploreHiddenPages - : multichainHiddenPages + const multichainHiddenPages = + isLandingPage || isSendPage || isSwapPage || isLimitPage || baseHiddenPages || isExplorePage - return hideChainSelector + return multichainHiddenPages } export default function Navbar() { diff --git a/apps/web/src/components/Pools/PoolDetails/PoolDetailsHeader.tsx b/apps/web/src/components/Pools/PoolDetails/PoolDetailsHeader.tsx index c445257167a..6509bb913d8 100644 --- a/apps/web/src/components/Pools/PoolDetails/PoolDetailsHeader.tsx +++ b/apps/web/src/components/Pools/PoolDetails/PoolDetailsHeader.tsx @@ -3,10 +3,10 @@ import { BreadcrumbNavContainer, BreadcrumbNavLink, CurrentPageBreadcrumb } from import { DropdownSelector } from 'components/DropdownSelector' import { EtherscanLogo } from 'components/Icons/Etherscan' import { ExplorerIcon } from 'components/Icons/ExplorerIcon' -import { ReverseArrow } from 'components/Icons/ReverseArrow' import CurrencyLogo from 'components/Logo/CurrencyLogo' import { DoubleCurrencyAndChainLogo } from 'components/Logo/DoubleLogo' import { DetailBubble } from 'components/Pools/PoolDetails/shared' +import { PoolDetailsBadge } from 'components/Pools/PoolTable/PoolTable' import ShareButton from 'components/Tokens/TokenDetails/ShareButton' import { ActionButtonStyle, ActionMenuFlyoutStyle } from 'components/Tokens/TokenDetails/shared' import { LoadingBubble } from 'components/Tokens/loading' @@ -20,8 +20,10 @@ import styled, { useTheme } from 'lib/styled-components' import React, { useMemo, useState } from 'react' import { ChevronRight, ExternalLink as ExternalLinkIcon } from 'react-feather' import { Link } from 'react-router-dom' -import { ClickableStyle, EllipsisStyle, ExternalLink, ThemedText } from 'theme/components' +import { ClickableStyle, ClickableTamaguiStyle, EllipsisStyle, ExternalLink, ThemedText } from 'theme/components' import { textFadeIn } from 'theme/styles' +import { Flex, TouchableArea } from 'ui/src' +import { ArrowUpDown } from 'ui/src/components/icons/ArrowUpDown' import { BIPS_BASE } from 'uniswap/src/constants/misc' import { ProtocolVersion, Token } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' import { Trans, t } from 'uniswap/src/i18n' @@ -46,17 +48,6 @@ const HeaderContainer = styled.div` animation-duration: ${({ theme }) => theme.transition.duration.medium}; ` -const Badge = styled(ThemedText.LabelMicro)` - background: ${({ theme }) => theme.surface2}; - padding: 2px 6px; - border-radius: 4px; -` - -const ToggleReverseArrows = styled(ReverseArrow)` - ${ClickableStyle} - fill: ${({ theme }) => theme.neutral2}; -` - const IconBubble = styled(LoadingBubble)` width: 32px; height: 32px; @@ -150,9 +141,26 @@ const PoolDetailsTitle = ({ - {protocolVersion === ProtocolVersion.V2 && v2} - {!!feePercent && {feePercent}} - + + + {protocolVersion?.toLowerCase()} + + {/* TODO(WEB-5364): add hook badge when data available, it should have a hover state and link out to the explorer */} + {!!feePercent && ( + + {feePercent} + + )} + + + + ) } diff --git a/apps/web/src/components/Pools/PoolDetails/PoolDetailsStatsButtons.tsx b/apps/web/src/components/Pools/PoolDetails/PoolDetailsStatsButtons.tsx index a1130fa14c7..5b48651865b 100644 --- a/apps/web/src/components/Pools/PoolDetails/PoolDetailsStatsButtons.tsx +++ b/apps/web/src/components/Pools/PoolDetails/PoolDetailsStatsButtons.tsx @@ -4,10 +4,9 @@ import useMultiChainPositions from 'components/AccountDrawer/MiniPortfolio/Pools import { CurrencySelect } from 'components/CurrencyInputPanel/SwapCurrencyInputPanel' import Column from 'components/deprecated/Column' import Row from 'components/deprecated/Row' -import { ReverseArrow } from 'components/Icons/ReverseArrow' import { SwapWrapperOuter } from 'components/swap/styled' import { LoadingBubble } from 'components/Tokens/loading' -import TokenSafetyMessage from 'components/TokenSafety/TokenSafetyMessage' +import TokenSafetyMessage from 'components/TokenSafety/DeprecatedTokenSafetyMessage' import { chainIdToBackendChain } from 'constants/chains' import { getPriorityWarning, StrongWarning, useTokenWarning } from 'constants/deprecatedTokenSafety' import { useTokenBalancesQuery } from 'graphql/data/apollo/AdaptiveTokenBalancesProvider' @@ -17,17 +16,24 @@ import { useAccount } from 'hooks/useAccount' import { useSwitchChain } from 'hooks/useSwitchChain' import styled from 'lib/styled-components' import { Swap } from 'pages/Swap' -import { useMemo, useReducer } from 'react' +import { useCallback, useMemo, useReducer, useState } from 'react' import { Plus, X } from 'react-feather' import { useLocation, useNavigate } from 'react-router-dom' import { BREAKPOINTS } from 'theme' import { ClickableStyle, ThemedText } from 'theme/components' import { opacify } from 'theme/utils' import { Z_INDEX } from 'theme/zIndex' +import { ArrowUpDown } from 'ui/src/components/icons/ArrowUpDown' import { Token } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' +import { CurrencyInfo } from 'uniswap/src/features/dataApi/types' +import { FeatureFlags } from 'uniswap/src/features/gating/flags' +import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' +import { TokenWarningCard } from 'uniswap/src/features/tokens/TokenWarningCard' +import TokenWarningModal from 'uniswap/src/features/tokens/TokenWarningModal' +import { useCurrencyInfo } from 'uniswap/src/features/tokens/useCurrencyInfo' import { Trans } from 'uniswap/src/i18n' import { UniverseChainId } from 'uniswap/src/types/chains' -import { currencyId } from 'utils/currencyId' +import { currencyId } from 'uniswap/src/utils/currencyId' import { NumberType, useFormatter } from 'utils/formatNumbers' const PoolDetailsStatsButtonsRow = styled(Row)` @@ -79,11 +85,6 @@ const PoolButton = styled.button<{ $open?: boolean; $hideOnMobile?: boolean; $fi } ` -const SwapIcon = styled(ReverseArrow)` - fill: ${({ theme }) => theme.accent1}; - rotate: 90deg; -` - const ButtonBubble = styled(LoadingBubble)` height: 44px; width: 175px; @@ -163,6 +164,8 @@ export function PoolDetailsStatsButtons({ chainId, token0, token1, feeTier, load const location = useLocation() const currency0 = token0 && gqlToCurrency(token0) const currency1 = token1 && gqlToCurrency(token1) + const currencyInfo0 = useCurrencyInfo(currency0 && currencyId(currency0)) + const currencyInfo1 = useCurrencyInfo(currency1 && currencyId(currency1)) // Mobile Balance Data const { data: balanceQuery } = useTokenBalancesQuery() @@ -199,7 +202,9 @@ export function PoolDetailsStatsButtons({ chainId, token0, token1, feeTier, load if (account.chainId !== chainId && chainId) { await switchChain(chainId) } - navigate(`/add/${currencyId(currency0)}/${currencyId(currency1)}/${feeTier}${tokenId ? `/${tokenId}` : ''}`, { + const currency0Address = currency0.isNative ? 'ETH' : currency0.address + const currency1Address = currency1.isNative ? 'ETH' : currency1.address + navigate(`/add/${currency0Address}/${currency1Address}/${feeTier}${tokenId ? `/${tokenId}` : ''}`, { state: { from: location.pathname }, }) } @@ -212,6 +217,15 @@ export function PoolDetailsStatsButtons({ chainId, token0, token1, feeTier, load const token1Warning = useTokenWarning(token1?.address, chainId) const priorityWarning = getPriorityWarning(token0Warning, token1Warning) + const tokenProtectionEnabled = useFeatureFlag(FeatureFlags.TokenProtection) + const [showWarningModal, setShowWarningModal] = useState(false) + const closeWarningModal = useCallback(() => setShowWarningModal(false), []) + const [warningModalCurrencyInfo, setWarningModalCurrencyInfo] = useState>() + const onWarningCardCtaPressed = useCallback((currencyInfo: Maybe) => { + setWarningModalCurrencyInfo(currencyInfo) + setShowWarningModal(true) + }, []) + if (loading || !currency0 || !currency1) { return ( @@ -259,7 +273,7 @@ export function PoolDetailsStatsButtons({ chainId, token0, token1, feeTier, load ) : ( <> - {screenSizeLargerThanTablet && } + {screenSizeLargerThanTablet && } @@ -287,13 +301,30 @@ export function PoolDetailsStatsButtons({ chainId, token0, token1, feeTier, load compact disableTokenInputs={chainId !== account.chainId} /> - {Boolean(priorityWarning) && ( - + {tokenProtectionEnabled ? ( + <> + onWarningCardCtaPressed(currencyInfo0)} /> + onWarningCardCtaPressed(currencyInfo1)} /> + {warningModalCurrencyInfo && ( + // Intentionally duplicative with the TokenWarningModal in the swap component; this one only displays when user clicks "i" Info button on the TokenWarningCard + + )} + + ) : ( + Boolean(priorityWarning) && ( + + ) )}
- 0.05% + + + 0.05% +
- - - + + + +
diff --git a/apps/web/src/components/Tokens/TokenTable/NetworkFilter.tsx b/apps/web/src/components/Tokens/TokenTable/NetworkFilter.tsx index 23c25849529..81fc1481e70 100644 --- a/apps/web/src/components/Tokens/TokenTable/NetworkFilter.tsx +++ b/apps/web/src/components/Tokens/TokenTable/NetworkFilter.tsx @@ -6,6 +6,7 @@ import { AllNetworksIcon } from 'components/Tokens/TokenTable/icons' import { BACKEND_NOT_YET_SUPPORTED_CHAIN_IDS, BACKEND_SUPPORTED_CHAINS, + BACKEND_SUPPORTED_TESTNET_CHAINS, InterfaceGqlChain, useChainFromUrlParam, useIsSupportedChainIdCallback, @@ -20,8 +21,7 @@ import { useNavigate } from 'react-router-dom' import { EllipsisTamaguiStyle } from 'theme/components' import { Flex, FlexProps, ScrollView, Text, styled } from 'ui/src' import { UNIVERSE_CHAIN_INFO } from 'uniswap/src/constants/chains' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' +import { useEnabledChains } from 'uniswap/src/features/settings/hooks' import Trace from 'uniswap/src/features/telemetry/Trace' import { ModalName } from 'uniswap/src/features/telemetry/constants' import { useTranslation } from 'uniswap/src/i18n' @@ -54,12 +54,10 @@ const StyledDropdown = { export default function TableNetworkFilter() { const [isMenuOpen, toggleMenu] = useState(false) const isSupportedChainCallback = useIsSupportedChainIdCallback() - const isMultichainExploreEnabled = useFeatureFlag(FeatureFlags.MultichainExplore) + const { isTestnetModeEnabled } = useEnabledChains() const exploreParams = useExploreParams() - const currentChain = getSupportedGraphQlChain(useChainFromUrlParam(), { - fallbackToEthereum: !isMultichainExploreEnabled, - }) + const currentChain = getSupportedGraphQlChain(useChainFromUrlParam()) const tab = exploreParams.tab return ( @@ -79,9 +77,7 @@ export default function TableNetworkFilter() { } internalMenuItems={ - {isMultichainExploreEnabled && ( - - )} + {BACKEND_SUPPORTED_CHAINS.map((network) => { const chainId = supportedChainIdFromGQLChain(network) const isSupportedChain = isSupportedChainCallback(chainId) @@ -96,6 +92,22 @@ export default function TableNetworkFilter() { /> ) : null })} + {isTestnetModeEnabled + ? BACKEND_SUPPORTED_TESTNET_CHAINS.map((network) => { + const chainId = supportedChainIdFromGQLChain(network) + const isSupportedChain = isSupportedChainCallback(chainId) + const chainInfo = isSupportedChain ? UNIVERSE_CHAIN_INFO[chainId] : undefined + return chainInfo ? ( + + ) : null + }) + : null} {BACKEND_NOT_YET_SUPPORTED_CHAIN_IDS.map((network) => { const isSupportedChain = isSupportedChainCallback(network) const chainInfo = isSupportedChain ? UNIVERSE_CHAIN_INFO[network] : undefined @@ -136,14 +148,10 @@ const TableNetworkItem = memo(function TableNetworkItem({ const navigate = useNavigate() const theme = useTheme() const { t } = useTranslation() - const isMultichainExploreEnabled = useFeatureFlag(FeatureFlags.MultichainExplore) const chainId = chainInfo?.id const exploreParams = useExploreParams() - const currentChain = getSupportedGraphQlChain( - useChainFromUrlParam(), - isMultichainExploreEnabled ? undefined : { fallbackToEthereum: true }, - ) - const isAllNetworks = display === 'All networks' && isMultichainExploreEnabled + const currentChain = getSupportedGraphQlChain(useChainFromUrlParam()) + const isAllNetworks = display === 'All networks' const isCurrentChain = isAllNetworks ? !currentChain : currentChain?.backendChain.chain === display && exploreParams.chainName diff --git a/apps/web/src/components/Tokens/TokenTable/index.tsx b/apps/web/src/components/Tokens/TokenTable/index.tsx index d046b73f08b..ddc97022dc8 100644 --- a/apps/web/src/components/Tokens/TokenTable/index.tsx +++ b/apps/web/src/components/Tokens/TokenTable/index.tsx @@ -18,10 +18,10 @@ import { useSetSortMethod, } from 'components/Tokens/state' import { MouseoverTooltip } from 'components/Tooltip' -import { chainIdToBackendChain, getChainFromChainUrlParam, useChainFromUrlParam } from 'constants/chains' +import { chainIdToBackendChain, getChainFromChainUrlParam } from 'constants/chains' import { NATIVE_CHAIN_ID } from 'constants/tokens' -import { SparklineMap, TopToken, useTopTokens } from 'graphql/data/TopTokens' -import { OrderDirection, getSupportedGraphQlChain, getTokenDetailsURL, unwrapToken } from 'graphql/data/util' +import { SparklineMap, TopToken } from 'graphql/data/types' +import { OrderDirection, getTokenDetailsURL, unwrapToken } from 'graphql/data/util' import useSimplePagination from 'hooks/useSimplePagination' import { useAtomValue } from 'jotai/utils' import { ReactElement, ReactNode, memo, useMemo } from 'react' @@ -29,8 +29,6 @@ import { TABLE_PAGE_SIZE, giveExploreStatDefaultValue } from 'state/explore' import { useTopTokens as useRestTopTokens } from 'state/explore/topTokens' import { TokenStat } from 'state/explore/types' import { Flex, Text, styled } from 'ui/src' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { Trans } from 'uniswap/src/i18n' import { NumberType, useFormatter } from 'utils/formatNumbers' @@ -89,65 +87,19 @@ function TokenDescription({ token }: { token: TopToken | TokenStat }) { } export const TopTokensTable = memo(function TopTokensTable() { - const chain = getSupportedGraphQlChain(useChainFromUrlParam(), { fallbackToEthereum: true }) - const isRestExploreEnabled = useFeatureFlag(FeatureFlags.RestExplore) - const { - tokens: gqlTokens, - tokenSortRank: gqlTokenSortRank, - loadingTokens: gqlLoadingTokens, - sparklines: gqlSparklines, - error: gqlError, - } = useTopTokens(chain.backendChain.chain, isRestExploreEnabled /* skip */) - const { - topTokens: restTopTokens, - tokenSortRank: restTokenSortRank, - isLoading: restIsLoading, - sparklines: restSparklines, - isError: restError, - } = useRestTopTokens() + const { topTokens, tokenSortRank, isLoading, sparklines, isError } = useRestTopTokens() const { page, loadMore } = useSimplePagination() - const { tokens, tokenSortRank, sparklines, loading, error } = useMemo(() => { - return isRestExploreEnabled - ? { - tokens: restTopTokens?.slice(0, page * TABLE_PAGE_SIZE), - tokenSortRank: restTokenSortRank, - loading: restIsLoading, - sparklines: restSparklines, - error: restError, - } - : { - tokens: gqlTokens, - tokenSortRank: gqlTokenSortRank, - loading: gqlLoadingTokens, - sparklines: gqlSparklines, - error: gqlError, - } - }, [ - isRestExploreEnabled, - restTopTokens, - page, - restTokenSortRank, - restIsLoading, - restSparklines, - restError, - gqlTokens, - gqlTokenSortRank, - gqlLoadingTokens, - gqlSparklines, - gqlError, - ]) - return ( ) diff --git a/apps/web/src/components/TopLevelModals/LaunchModal.tsx b/apps/web/src/components/TopLevelModals/LaunchModal.tsx index 177a6e9f067..779c5a45075 100644 --- a/apps/web/src/components/TopLevelModals/LaunchModal.tsx +++ b/apps/web/src/components/TopLevelModals/LaunchModal.tsx @@ -81,12 +81,19 @@ export function LaunchModal({
- - diff --git a/apps/web/src/components/swap/__snapshots__/SwapLineItem.test.tsx.snap b/apps/web/src/components/swap/__snapshots__/SwapLineItem.test.tsx.snap index c13a1a82b92..2ae168375f3 100644 --- a/apps/web/src/components/swap/__snapshots__/SwapLineItem.test.tsx.snap +++ b/apps/web/src/components/swap/__snapshots__/SwapLineItem.test.tsx.snap @@ -10,105 +10,6 @@ exports[`SwapLineItem.tsx dutch order eth input 1`] = ` transition: opacity 250ms ease-in-out; } -.c0 { - box-sizing: border-box; - margin: 0; - min-width: 0; -} - -.c19 { - box-sizing: border-box; - margin: 0; - min-width: 0; - width: auto; -} - -.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; -} - -.c8 { - 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; - gap: 4px; -} - -.c11 { - 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; - gap: 8px; -} - -.c20 { - width: auto; - 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; - gap: 4px; -} - -.c2 { - -webkit-box-pack: justify; - -webkit-justify-content: space-between; - -ms-flex-pack: justify; - justify-content: space-between; -} - -.c9 { - position: relative; - width: -webkit-fit-content; - width: -moz-fit-content; - width: fit-content; - margin: -xs; -} - .c12 { color: #4673fa; } @@ -236,6 +137,80 @@ exports[`SwapLineItem.tsx dutch order eth input 1`] = ` box-shadow: 0 4px 8px 0 rgba(47,128,237,0.1); } +.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; +} + +.c8 { + 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; + gap: 4px; +} + +.c11 { + 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; + gap: 8px; +} + +.c2 { + -webkit-box-pack: justify; + -webkit-justify-content: space-between; + -ms-flex-pack: justify; + justify-content: space-between; +} + +.c9 { + position: relative; + width: -webkit-fit-content; + width: -moz-fit-content; + width: fit-content; + margin: -xs; +} + .c4 { cursor: help; color: #7D7D7D; @@ -616,9 +591,7 @@ exports[`SwapLineItem.tsx dutch order eth input 1`] = ` class="c3 c6 css-142zc9n" >
* { - fill: #CECECE; } -.c17 { +.c8 { + width: 100%; display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; display: flex; - background: #22222212; - border-radius: 8px; - color: #7D7D7D; - height: 20px; - padding: 0 6px; + padding: 0; -webkit-align-items: center; -webkit-box-align: center; -ms-flex-align: center; align-items: center; -} - - * { + fill: #CECECE; +} + +.c17 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + background: #22222212; + border-radius: 8px; + color: #7D7D7D; + height: 20px; + padding: 0 6px; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; +} + + -
-
-
- Best price route costs ~$1.00 in gas. This route optimizes your total output by considering split routes, multiple hops, and the gas cost of each step. -
- - - -
-
- - - - - - -`; - -exports[`SwapLineItem.tsx exact output 1`] = ` - - .c7 { - -webkit-filter: none; - filter: none; - opacity: 1; - -webkit-transition: opacity 250ms ease-in-out; - transition: opacity 250ms ease-in-out; -} - -.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; -} - -.c8 { - 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; - gap: 4px; -} - -.c12 { - 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; - gap: 8px; -} - -.c2 { - -webkit-box-pack: justify; - -webkit-justify-content: space-between; - -ms-flex-pack: justify; - justify-content: space-between; -} + aria-expanded="false" + aria-haspopup="dialog" + class="_display-flex _alignItems-stretch _flexDirection-column _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0" + data-state="closed" + > +
+
+
+
+
+
+
+ + 1% + +
+
+
+ + + +
+ Best price route costs ~$1.00 in gas. This route optimizes your total output by considering split routes, multiple hops, and the gas cost of each step. +
+ + + +
+
+ + + + + +
+`; -.c9 { - position: relative; - width: -webkit-fit-content; - width: -moz-fit-content; - width: fit-content; - margin: -xs; +exports[`SwapLineItem.tsx exact output 1`] = ` + + .c7 { + -webkit-filter: none; + filter: none; + opacity: 1; + -webkit-transition: opacity 250ms ease-in-out; + transition: opacity 250ms ease-in-out; } .c3 { @@ -3077,6 +2949,80 @@ exports[`SwapLineItem.tsx exact output 1`] = ` box-shadow: 0 4px 8px 0 rgba(47,128,237,0.1); } +.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; +} + +.c8 { + 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; + gap: 4px; +} + +.c12 { + 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; + gap: 8px; +} + +.c2 { + -webkit-box-pack: justify; + -webkit-justify-content: space-between; + -ms-flex-pack: justify; + justify-content: space-between; +} + +.c9 { + position: relative; + width: -webkit-fit-content; + width: -moz-fit-content; + width: fit-content; + margin: -xs; +} + .c4 { cursor: help; color: #7D7D7D; @@ -3567,125 +3513,51 @@ exports[`SwapLineItem.tsx exact output 1`] = ` />
-
- - - 0.3% - - - - - - - -
- This route optimizes your total output by considering split routes, multiple hops, and the gas cost of each step. -
- - - -
-
- - - - - -
-`; - -exports[`SwapLineItem.tsx fee on buy 1`] = ` - - .c7 { - -webkit-filter: none; - filter: none; - opacity: 1; - -webkit-transition: opacity 250ms ease-in-out; - transition: opacity 250ms ease-in-out; -} - -.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; -} - -.c8 { - 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; - gap: 4px; -} - -.c12 { - 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; - gap: 8px; -} - -.c2 { - -webkit-box-pack: justify; - -webkit-justify-content: space-between; - -ms-flex-pack: justify; - justify-content: space-between; -} + data-testid="output-currency-logo-container" + /> + + + + 0.3% + + + + + + + +
+ This route optimizes your total output by considering split routes, multiple hops, and the gas cost of each step. +
+ + + +
+
+ + + + + +
+`; -.c9 { - position: relative; - width: -webkit-fit-content; - width: -moz-fit-content; - width: fit-content; - margin: -xs; +exports[`SwapLineItem.tsx fee on buy 1`] = ` + + .c7 { + -webkit-filter: none; + filter: none; + opacity: 1; + -webkit-transition: opacity 250ms ease-in-out; + transition: opacity 250ms ease-in-out; } .c3 { @@ -3835,6 +3707,80 @@ exports[`SwapLineItem.tsx fee on buy 1`] = ` box-shadow: 0 4px 8px 0 rgba(47,128,237,0.1); } +.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; +} + +.c8 { + 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; + gap: 4px; +} + +.c12 { + 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; + gap: 8px; +} + +.c2 { + -webkit-box-pack: justify; + -webkit-justify-content: space-between; + -ms-flex-pack: justify; + justify-content: space-between; +} + +.c9 { + position: relative; + width: -webkit-fit-content; + width: -moz-fit-content; + width: fit-content; + margin: -xs; +} + .c4 { cursor: help; color: #7D7D7D; @@ -4318,145 +4264,71 @@ exports[`SwapLineItem.tsx fee on buy 1`] = ` style="justify-content: space-evenly; z-index: 2;" > - - -
- Best price route costs ~$1.00 in gas. This route optimizes your total output by considering split routes, multiple hops, and the gas cost of each step. -
- - - -
-
- - - - - -
-`; - -exports[`SwapLineItem.tsx fee on sell 1`] = ` - - .c7 { - -webkit-filter: none; - filter: none; - opacity: 1; - -webkit-transition: opacity 250ms ease-in-out; - transition: opacity 250ms ease-in-out; -} - -.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; -} - -.c8 { - 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; - gap: 4px; -} - -.c12 { - 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; - gap: 8px; -} - -.c2 { - -webkit-box-pack: justify; - -webkit-justify-content: space-between; - -ms-flex-pack: justify; - justify-content: space-between; -} + aria-expanded="false" + aria-haspopup="dialog" + class="_display-flex _alignItems-stretch _flexDirection-column _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0" + data-state="closed" + > +
+
+
+
+
+
+
+ + 1% + +
+
+
+ + + +
+ Best price route costs ~$1.00 in gas. This route optimizes your total output by considering split routes, multiple hops, and the gas cost of each step. +
+ + + +
+
+ + + + + +
+`; -.c9 { - position: relative; - width: -webkit-fit-content; - width: -moz-fit-content; - width: fit-content; - margin: -xs; +exports[`SwapLineItem.tsx fee on sell 1`] = ` + + .c7 { + -webkit-filter: none; + filter: none; + opacity: 1; + -webkit-transition: opacity 250ms ease-in-out; + transition: opacity 250ms ease-in-out; } .c3 { @@ -4606,6 +4478,80 @@ exports[`SwapLineItem.tsx fee on sell 1`] = ` box-shadow: 0 4px 8px 0 rgba(47,128,237,0.1); } +.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; +} + +.c8 { + 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; + gap: 4px; +} + +.c12 { + 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; + gap: 8px; +} + +.c2 { + -webkit-box-pack: justify; + -webkit-justify-content: space-between; + -ms-flex-pack: justify; + justify-content: space-between; +} + +.c9 { + position: relative; + width: -webkit-fit-content; + width: -moz-fit-content; + width: fit-content; + margin: -xs; +} + .c4 { cursor: help; color: #7D7D7D; @@ -5161,54 +5107,6 @@ exports[`SwapLineItem.tsx preview exact in 1`] = ` width: 50px; } -.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; -} - -.c13 { - 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; - gap: 8px; -} - -.c2 { - -webkit-box-pack: justify; - -webkit-justify-content: space-between; - -ms-flex-pack: justify; - justify-content: space-between; -} - .c3 { color: #222222; -webkit-letter-spacing: -0.01em; @@ -5332,6 +5230,54 @@ exports[`SwapLineItem.tsx preview exact in 1`] = ` box-shadow: 0 4px 8px 0 rgba(47,128,237,0.1); } +.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; +} + +.c13 { + 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; + gap: 8px; +} + +.c2 { + -webkit-box-pack: justify; + -webkit-justify-content: space-between; + -ms-flex-pack: justify; + justify-content: space-between; +} + .c4 { cursor: help; color: #7D7D7D; @@ -5618,6 +5564,14 @@ exports[`SwapLineItem.tsx syncing 1`] = ` width: 70px; } +.c3 { + color: #222222; + -webkit-letter-spacing: -0.01em; + -moz-letter-spacing: -0.01em; + -ms-letter-spacing: -0.01em; + letter-spacing: -0.01em; +} + .c0 { box-sizing: border-box; margin: 0; @@ -5648,14 +5602,6 @@ exports[`SwapLineItem.tsx syncing 1`] = ` justify-content: space-between; } -.c3 { - color: #222222; - -webkit-letter-spacing: -0.01em; - -moz-letter-spacing: -0.01em; - -ms-letter-spacing: -0.01em; - letter-spacing: -0.01em; -} - .c4 { cursor: help; color: #7D7D7D; diff --git a/apps/web/src/constants/chains.test.ts b/apps/web/src/constants/chains.test.ts index 083879b10e7..741cf2187b4 100644 --- a/apps/web/src/constants/chains.test.ts +++ b/apps/web/src/constants/chains.test.ts @@ -1,6 +1,7 @@ import { BACKEND_NOT_YET_SUPPORTED_CHAIN_IDS, BACKEND_SUPPORTED_CHAINS, + BACKEND_SUPPORTED_TESTNET_CHAINS, CHAIN_IDS_TO_NAMES, CHAIN_ID_TO_BACKEND_NAME, CHAIN_NAME_TO_CHAIN_ID, @@ -175,6 +176,16 @@ test.each(backendSupportedChains)( }, ) +const backendSupportedTestnetChains = [Chain.EthereumSepolia, Chain.AstrochainSepolia] as const + +test.each(backendSupportedTestnetChains)( + 'BACKEND_SUPPORTED_TESTNET_CHAINS generates the correct chains', + (chain: InterfaceGqlChain) => { + expect(BACKEND_SUPPORTED_TESTNET_CHAINS.includes(chain)).toBe(true) + expect(BACKEND_SUPPORTED_TESTNET_CHAINS.length).toEqual(backendSupportedTestnetChains.length) + }, +) + const backendNotyetSupportedChainIds = [] as const test('BACKEND_NOT_YET_SUPPORTED_CHAIN_IDS array is empty', () => { diff --git a/apps/web/src/constants/chains.ts b/apps/web/src/constants/chains.ts index 5c263661f53..362f3fb4bce 100644 --- a/apps/web/src/constants/chains.ts +++ b/apps/web/src/constants/chains.ts @@ -134,6 +134,17 @@ export const BACKEND_SUPPORTED_CHAINS = Object.keys(UNIVERSE_CHAIN_INFO) }) .map((key) => UNIVERSE_CHAIN_INFO[parseInt(key) as UniverseChainId].backendChain.chain as InterfaceGqlChain) +export const BACKEND_SUPPORTED_TESTNET_CHAINS = Object.keys(UNIVERSE_CHAIN_INFO) + .filter((key) => { + const chainId = parseInt(key) as UniverseChainId + return ( + UNIVERSE_CHAIN_INFO[chainId].backendChain.backendSupported && + !UNIVERSE_CHAIN_INFO[chainId].backendChain.isSecondaryChain && + UNIVERSE_CHAIN_INFO[chainId].testnet + ) + }) + .map((key) => UNIVERSE_CHAIN_INFO[parseInt(key) as UniverseChainId].backendChain.chain as InterfaceGqlChain) + export const BACKEND_NOT_YET_SUPPORTED_CHAIN_IDS = GQL_MAINNET_CHAINS.filter( (chain) => !BACKEND_SUPPORTED_CHAINS.includes(chain), ).map((chain) => CHAIN_NAME_TO_CHAIN_ID[chain]) as [UniverseChainId] diff --git a/apps/web/src/graphql/data/TopTokens.ts b/apps/web/src/graphql/data/TopTokens.ts deleted file mode 100644 index 68a41be69b2..00000000000 --- a/apps/web/src/graphql/data/TopTokens.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { ApolloError } from '@apollo/client' -import { - exploreSearchStringAtom, - filterTimeAtom, - sortAscendingAtom, - sortMethodAtom, - TokenSortMethod, -} from 'components/Tokens/state' -import { - isPricePoint, - PollingInterval, - PricePoint, - supportedChainIdFromGQLChain, - toHistoryDuration, - unwrapToken, - usePollQueryWhileMounted, -} from 'graphql/data/util' -import useIsWindowVisible from 'hooks/useIsWindowVisible' -import { useAtomValue } from 'jotai/utils' -import { useMemo } from 'react' -import { - Chain, - TopTokens100Query, - useTopTokens100Query, - useTopTokensSparklineQuery, -} from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' - -const TokenSortMethods = { - [TokenSortMethod.PRICE]: (a: TopToken, b: TopToken) => - (b?.market?.price?.value ?? 0) - (a?.market?.price?.value ?? 0), - [TokenSortMethod.DAY_CHANGE]: (a: TopToken, b: TopToken) => - (b?.market?.pricePercentChange1Day?.value ?? 0) - (a?.market?.pricePercentChange1Day?.value ?? 0), - [TokenSortMethod.HOUR_CHANGE]: (a: TopToken, b: TopToken) => - (b?.market?.pricePercentChange1Hour?.value ?? 0) - (a?.market?.pricePercentChange1Hour?.value ?? 0), - [TokenSortMethod.VOLUME]: (a: TopToken, b: TopToken) => - (b?.market?.volume?.value ?? 0) - (a?.market?.volume?.value ?? 0), - [TokenSortMethod.FULLY_DILUTED_VALUATION]: (a: TopToken, b: TopToken) => - (b?.project?.markets?.[0]?.fullyDilutedValuation?.value ?? 0) - - (a?.project?.markets?.[0]?.fullyDilutedValuation?.value ?? 0), -} - -function useSortedTokens(tokens: TopTokens100Query['topTokens']) { - const sortMethod = useAtomValue(sortMethodAtom) - const sortAscending = useAtomValue(sortAscendingAtom) - - return useMemo(() => { - if (!tokens) { - return undefined - } - const tokenArray = Array.from(tokens).sort(TokenSortMethods[sortMethod]) - - return sortAscending ? tokenArray.reverse() : tokenArray - }, [tokens, sortMethod, sortAscending]) -} - -function useFilteredTokens(tokens: TopTokens100Query['topTokens']) { - const filterString = useAtomValue(exploreSearchStringAtom) - - const lowercaseFilterString = useMemo(() => filterString.toLowerCase(), [filterString]) - - return useMemo(() => { - if (!tokens) { - return undefined - } - let returnTokens = tokens - if (lowercaseFilterString) { - returnTokens = returnTokens?.filter((token) => { - const addressIncludesFilterString = token?.address?.toLowerCase().includes(lowercaseFilterString) - const projectNameIncludesFilterString = token?.project?.name?.toLowerCase().includes(lowercaseFilterString) - const nameIncludesFilterString = token?.name?.toLowerCase().includes(lowercaseFilterString) - const symbolIncludesFilterString = token?.symbol?.toLowerCase().includes(lowercaseFilterString) - return ( - projectNameIncludesFilterString || - nameIncludesFilterString || - symbolIncludesFilterString || - addressIncludesFilterString - ) - }) - } - return returnTokens - }, [tokens, lowercaseFilterString]) -} - -export type SparklineMap = { [key: string]: PricePoint[] | undefined } -export type TopToken = NonNullable['topTokens']>[number] - -interface UseTopTokensReturnValue { - tokens?: readonly TopToken[] - tokenSortRank: Record - loadingTokens: boolean - sparklines: SparklineMap - error?: ApolloError -} - -export function useTopTokens(chain: Chain, skip?: boolean): UseTopTokensReturnValue { - const chainId = supportedChainIdFromGQLChain(chain) - const duration = toHistoryDuration(useAtomValue(filterTimeAtom)) - const isWindowVisible = useIsWindowVisible() - - const { data: sparklineQuery } = usePollQueryWhileMounted( - useTopTokensSparklineQuery({ - variables: { duration, chain }, - skip: !isWindowVisible || skip, - }), - PollingInterval.Slow, - ) - - const sparklines = useMemo(() => { - const unwrappedTokens = chainId && sparklineQuery?.topTokens?.map((topToken) => unwrapToken(chainId, topToken)) - const map: SparklineMap = {} - unwrappedTokens?.forEach((current) => { - if (current?.address !== undefined) { - map[current.address] = current?.market?.priceHistory?.filter(isPricePoint) as PricePoint[] - } - }) - return map - }, [chainId, sparklineQuery?.topTokens]) - - const { - data, - loading: loadingTokens, - error, - } = usePollQueryWhileMounted( - useTopTokens100Query({ - variables: { duration, chain }, - skip: !isWindowVisible || skip, - }), - PollingInterval.Fast, - ) - - const unwrappedTokens = useMemo( - () => chainId && data?.topTokens?.map((token) => unwrapToken(chainId, token)), - [chainId, data], - ) - const sortedTokens = useSortedTokens(unwrappedTokens) - const tokenSortRank = useMemo( - () => - sortedTokens?.reduce((acc, cur, i) => { - if (!cur?.address) { - return acc - } - return { - ...acc, - [cur.address]: i + 1, - } - }, {}) ?? {}, - [sortedTokens], - ) - const filteredTokens = useFilteredTokens(sortedTokens) - return useMemo( - () => ({ tokens: filteredTokens, tokenSortRank, loadingTokens, sparklines, error }), - [filteredTokens, tokenSortRank, loadingTokens, sparklines, error], - ) -} diff --git a/apps/web/src/graphql/data/apollo/TokenBalancesProvider.test.tsx b/apps/web/src/graphql/data/apollo/TokenBalancesProvider.test.tsx index b4f02e14ec4..4134d5a09ec 100644 --- a/apps/web/src/graphql/data/apollo/TokenBalancesProvider.test.tsx +++ b/apps/web/src/graphql/data/apollo/TokenBalancesProvider.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, screen } from '@testing-library/react' +import { fireEvent, screen, waitFor } from '@testing-library/react' import { PrefetchBalancesWrapper, useTokenBalancesQuery } from 'graphql/data/apollo/AdaptiveTokenBalancesProvider' import { useAccount } from 'hooks/useAccount' import { mocked } from 'test-utils/mocked' @@ -7,6 +7,8 @@ import { useOnAssetActivitySubscription } from 'uniswap/src/data/graphql/uniswap import { FeatureFlags } from 'uniswap/src/features/gating/flags' import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' +// TODO(WEB-5370): Remove this delay + waitFor once we've integrated wallet's refetch logic +jest.setTimeout(10000) const mockLazyFetch = jest.fn() const mockBalanceQueryResponse = [ mockLazyFetch, @@ -45,17 +47,17 @@ describe('TokenBalancesProvider', () => { mocked(useAccount).mockReturnValue({ address: '0xaddress1', chainId: 1 } as any) }) - it('TokenBalancesProvider should not fetch balances without calls to useOnAssetActivitySubscription', () => { + it('TokenBalancesProvider should not fetch balances without calls to useOnAssetActivitySubscription', async () => { render(
) - expect(mockLazyFetch).toHaveBeenCalledTimes(0) + await waitFor(() => expect(mockLazyFetch).toHaveBeenCalledTimes(0), { timeout: 3500 }) }) describe('useTokenBalancesQuery', () => { - it('should only refetch balances when stale', () => { + it('should only refetch balances when stale', async () => { const { rerender, unmount } = renderHook(() => useTokenBalancesQuery()) // Rendering useTokenBalancesQuery should trigger a fetch - expect(mockLazyFetch).toHaveBeenCalledTimes(1) + await waitFor(() => expect(mockLazyFetch).toHaveBeenCalledTimes(1), { timeout: 3500 }) // Rerender to clear staleness rerender() @@ -63,31 +65,31 @@ describe('TokenBalancesProvider', () => { // Receiving a new value from subscription should trigger a fetch while useTokenBalancesQuery hooks are mounted triggerSubscriptionUpdate() rerender() - expect(mockLazyFetch).toHaveBeenCalledTimes(2) + await waitFor(() => expect(mockLazyFetch).toHaveBeenCalledTimes(2), { timeout: 3500 }) // Unmounting the hooks should not trigger any fetches unmount() - expect(mockLazyFetch).toHaveBeenCalledTimes(2) + await waitFor(() => expect(mockLazyFetch).toHaveBeenCalledTimes(2), { timeout: 3500 }) // Receiving a new value from subscription should NOT trigger a fetch if no useTokenBalancesQuery hooks are mounted triggerSubscriptionUpdate() - expect(mockLazyFetch).toHaveBeenCalledTimes(2) + await waitFor(() => expect(mockLazyFetch).toHaveBeenCalledTimes(2), { timeout: 3500 }) }) - it('should use cached balances across multiple hook calls', () => { + it('should use cached balances across multiple hook calls', async () => { renderHook(() => ({ hook1: useTokenBalancesQuery(), hook2: useTokenBalancesQuery(), })) // Rendering useTokenBalancesQuery twice should only trigger one fetch - expect(mockLazyFetch).toHaveBeenCalledTimes(1) + await waitFor(() => expect(mockLazyFetch).toHaveBeenCalledTimes(1), { timeout: 3500 }) }) - it('should refetch when account changes', () => { + it('should refetch when account changes', async () => { const { rerender } = renderHook(() => useTokenBalancesQuery()) - expect(mockLazyFetch).toHaveBeenCalledTimes(1) + await waitFor(() => expect(mockLazyFetch).toHaveBeenCalledTimes(1), { timeout: 3500 }) // Rerender to clear staleness rerender() @@ -96,12 +98,12 @@ describe('TokenBalancesProvider', () => { mocked(useAccount).mockReturnValue({ address: '0xaddress2', chainId: 1 } as any) rerender() - expect(mockLazyFetch).toHaveBeenCalledTimes(2) + await waitFor(() => expect(mockLazyFetch).toHaveBeenCalledTimes(2), { timeout: 3500 }) }) }) describe('PrefetchBalancesWrapper', () => { - it('should fetch balances when a PrefetchBalancesWrapper is hovered', () => { + it('should fetch balances when a PrefetchBalancesWrapper is hovered', async () => { const { rerender } = render(
hi
@@ -117,17 +119,17 @@ describe('TokenBalancesProvider', () => { ) // Should not fetch balances before hover - expect(mockLazyFetch).toHaveBeenCalledTimes(0) + await waitFor(() => expect(mockLazyFetch).toHaveBeenCalledTimes(0), { timeout: 3500 }) // Hovering component should trigger a fetch fireEvent.mouseEnter(wrappedComponent) fireEvent.mouseLeave(wrappedComponent) - expect(mockLazyFetch).toHaveBeenCalledTimes(1) + await waitFor(() => expect(mockLazyFetch).toHaveBeenCalledTimes(1), { timeout: 4000 }) // Subsequent hover should not trigger a fetch fireEvent.mouseEnter(wrappedComponent) fireEvent.mouseLeave(wrappedComponent) - expect(mockLazyFetch).toHaveBeenCalledTimes(1) + await waitFor(() => expect(mockLazyFetch).toHaveBeenCalledTimes(1), { timeout: 4000 }) // Subsequent hover should trigger a fetch if the subscription has updated triggerSubscriptionUpdate() @@ -136,10 +138,10 @@ describe('TokenBalancesProvider', () => {
hi
, ) - expect(mockLazyFetch).toHaveBeenCalledTimes(1) + await waitFor(() => expect(mockLazyFetch).toHaveBeenCalledTimes(1), { timeout: 4000 }) fireEvent.mouseEnter(wrappedComponent) fireEvent.mouseLeave(wrappedComponent) - expect(mockLazyFetch).toHaveBeenCalledTimes(2) + await waitFor(() => expect(mockLazyFetch).toHaveBeenCalledTimes(2), { timeout: 4000 }) }) }) }) diff --git a/apps/web/src/graphql/data/apollo/TokenBalancesProvider.tsx b/apps/web/src/graphql/data/apollo/TokenBalancesProvider.tsx index 2747a311fc6..1e84cdd3830 100644 --- a/apps/web/src/graphql/data/apollo/TokenBalancesProvider.tsx +++ b/apps/web/src/graphql/data/apollo/TokenBalancesProvider.tsx @@ -102,22 +102,37 @@ export function TokenBalancesProvider({ children }: PropsWithChildren) { if (!account.address) { return } - lazyFetch({ - variables: { - ownerAddress: account.address, - chains: gqlChains, - valueModifiers: [ - { - ownerAddress: account.address, - includeSpamTokens: valueModifiers.includeSpamTokens, - includeSmallBalances: valueModifiers.includeSmallBalances, - tokenExcludeOverrides: [], - tokenIncludeOverrides: [], - }, - ], + // adds a 3 second delay to account for dependency latency after an account update + // TODO(WEB-5370): Remove this delay once we've integrated wallet's refetch logic + setTimeout( + () => { + account.address && + lazyFetch({ + variables: { + ownerAddress: account.address, + chains: gqlChains, + valueModifiers: [ + { + ownerAddress: account.address, + includeSpamTokens: valueModifiers.includeSpamTokens, + includeSmallBalances: valueModifiers.includeSmallBalances, + tokenExcludeOverrides: [], + tokenIncludeOverrides: [], + }, + ], + }, + }) }, - }) - }, [account.address, lazyFetch, valueModifiers, gqlChains]) + hasAccountUpdate ? 3000 : 0, + ) + }, [ + account.address, + hasAccountUpdate, + lazyFetch, + gqlChains, + valueModifiers.includeSpamTokens, + valueModifiers.includeSmallBalances, + ]) return ( , Record>, +): Reference | StoreObject { + if (existing && !incoming) { + return existing + } + return mergeObjects(existing, incoming) +} diff --git a/apps/web/src/graphql/data/pools/useTopPools.ts b/apps/web/src/graphql/data/pools/useTopPools.ts index 5fc3cbd0929..10456cbde86 100644 --- a/apps/web/src/graphql/data/pools/useTopPools.ts +++ b/apps/web/src/graphql/data/pools/useTopPools.ts @@ -1,20 +1,7 @@ import { Percent } from '@uniswap/sdk-core' -import { exploreSearchStringAtom } from 'components/Tokens/state' -import { chainIdToBackendChain } from 'constants/chains' import { OrderDirection } from 'graphql/data/util' -import useIsWindowVisible from 'hooks/useIsWindowVisible' -import { useAtomValue } from 'jotai/utils' -import { useMemo } from 'react' import { BIPS_BASE } from 'uniswap/src/constants/misc' -import { - ProtocolVersion, - Token, - useTopV2PairsQuery, - useTopV3PoolsQuery, -} from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' -import { UniverseChainId } from 'uniswap/src/types/chains' +import { ProtocolVersion, Token } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' export function sortPools(pools: TablePool[], sortState: PoolTableSortState) { return pools.sort((a, b) => { @@ -93,92 +80,3 @@ export type PoolTableSortState = { sortBy: PoolSortFields sortDirection: OrderDirection } - -function useFilteredPools(pools: TablePool[]) { - const filterString = useAtomValue(exploreSearchStringAtom) - - const lowercaseFilterString = useMemo(() => filterString.toLowerCase(), [filterString]) - - return useMemo( - () => - pools.filter((pool) => { - const addressIncludesFilterString = pool.hash.toLowerCase().includes(lowercaseFilterString) - const token0IncludesFilterString = pool.token0?.symbol?.toLowerCase().includes(lowercaseFilterString) - const token1IncludesFilterString = pool.token1?.symbol?.toLowerCase().includes(lowercaseFilterString) - const token0HashIncludesFilterString = pool.token0?.address?.toLowerCase().includes(lowercaseFilterString) - const token1HashIncludesFilterString = pool.token1?.address?.toLowerCase().includes(lowercaseFilterString) - const poolName = `${pool.token0?.symbol}/${pool.token1?.symbol}`.toLowerCase() - const poolNameIncludesFilterString = poolName.includes(lowercaseFilterString) - return ( - token0IncludesFilterString || - token1IncludesFilterString || - addressIncludesFilterString || - token0HashIncludesFilterString || - token1HashIncludesFilterString || - poolNameIncludesFilterString - ) - }), - [lowercaseFilterString, pools], - ) -} - -export function useTopPools(sortState: PoolTableSortState, chainId?: UniverseChainId) { - const isWindowVisible = useIsWindowVisible() - const isRestExploreEnabled = useFeatureFlag(FeatureFlags.RestExplore) - const { - loading: loadingV3, - error: errorV3, - data: dataV3, - } = useTopV3PoolsQuery({ - variables: { first: 100, chain: chainIdToBackendChain({ chainId, withFallback: true }) }, - skip: !isWindowVisible || isRestExploreEnabled, - }) - const { - loading: loadingV2, - error: errorV2, - data: dataV2, - } = useTopV2PairsQuery({ - variables: { first: 100, chain: chainIdToBackendChain({ chainId, withFallback: true }) }, - skip: !isWindowVisible || !chainId || isRestExploreEnabled, - }) - const loading = loadingV3 || loadingV2 - - const unfilteredPools = useMemo(() => { - // TODO(WEB-4818): add v4 pools here - const topV3Pools: TablePool[] = - dataV3?.topV3Pools?.map((pool) => { - return { - hash: pool.address, - token0: pool.token0, - token1: pool.token1, - tvl: pool.totalLiquidity?.value, - volume24h: pool.volume24h?.value, - volumeWeek: pool.volumeWeek?.value, - apr: calculateApr(pool.volume24h?.value, pool.totalLiquidity?.value, pool.feeTier), - volOverTvl: calculate1DVolOverTvl(pool.volume24h?.value, pool.totalLiquidity?.value), - feeTier: pool.feeTier, - protocolVersion: pool.protocolVersion, - } as TablePool - }) ?? [] - const topV2Pairs: TablePool[] = - dataV2?.topV2Pairs?.map((pool) => { - return { - hash: pool.address, - token0: pool.token0, - token1: pool.token1, - tvl: pool.totalLiquidity?.value, - volume24h: pool.volume24h?.value, - volumeWeek: pool.volumeWeek?.value, - volOverTvl: calculate1DVolOverTvl(pool.volume24h?.value, pool.totalLiquidity?.value), - apr: calculateApr(pool.volume24h?.value, pool.totalLiquidity?.value, V2_BIPS), - feeTier: V2_BIPS, - protocolVersion: pool.protocolVersion, - } as TablePool - }) ?? [] - - return sortPools([...topV3Pools, ...topV2Pairs], sortState) - }, [dataV2?.topV2Pairs, dataV3?.topV3Pools, sortState]) - - const filteredPools = useFilteredPools(unfilteredPools).slice(0, 100) - return { topPools: filteredPools, loading, errorV3, errorV2 } -} diff --git a/apps/web/src/graphql/data/protocolStats.ts b/apps/web/src/graphql/data/protocolStats.ts deleted file mode 100644 index 7195dcfb580..00000000000 --- a/apps/web/src/graphql/data/protocolStats.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { StackedLineData } from 'components/Charts/StackedLineChart' -import { StackedHistogramData } from 'components/Charts/VolumeChart/renderer' -import { ChartType } from 'components/Charts/utils' -import { ChartQueryResult, checkDataQuality } from 'components/Tokens/TokenDetails/ChartSection/util' -import useIsWindowVisible from 'hooks/useIsWindowVisible' -import { UTCTimestamp } from 'lightweight-charts' -import { useMemo } from 'react' -import { - Chain, - HistoryDuration, - PriceSource, - ProtocolVersion, - TimestampedAmount, - useDailyProtocolTvlQuery, - useHistoricalProtocolVolumeQuery, -} from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' - -function mapDataByTimestamp( - v2Data?: readonly TimestampedAmount[], - v3Data?: readonly TimestampedAmount[], -): Record> { - const dataByTime: Record> = {} - v2Data?.forEach((v2Point) => { - const timestamp = v2Point.timestamp - dataByTime[timestamp] = { [ProtocolVersion.V2]: v2Point.value, [ProtocolVersion.V3]: 0, [ProtocolVersion.V4]: 0 } - }) - v3Data?.forEach((v3Point) => { - const timestamp = v3Point.timestamp - if (!dataByTime[timestamp]) { - dataByTime[timestamp] = { [ProtocolVersion.V4]: 0, [ProtocolVersion.V2]: 0, [ProtocolVersion.V3]: v3Point.value } - } else { - dataByTime[timestamp][ProtocolVersion.V3] = v3Point.value - } - }) - return dataByTime -} - -export function useHistoricalProtocolVolume( - chain: Chain, - duration: HistoryDuration, -): ChartQueryResult { - const isWindowVisible = useIsWindowVisible() - const isRestExploreEnabled = useFeatureFlag(FeatureFlags.RestExplore) - const { data: queryData, loading } = useHistoricalProtocolVolumeQuery({ - variables: { chain, duration }, - skip: !isWindowVisible || isRestExploreEnabled, - }) - - return useMemo(() => { - const dataByTime = mapDataByTimestamp(queryData?.v2HistoricalProtocolVolume, queryData?.v3HistoricalProtocolVolume) - - const entries = Object.entries(dataByTime).reduce((acc, [timestamp, values]) => { - acc.push({ - time: Number(timestamp) as UTCTimestamp, - values: { - [PriceSource.SubgraphV2]: values[ProtocolVersion.V2], - [PriceSource.SubgraphV3]: values[ProtocolVersion.V3], - [PriceSource.SubgraphV4]: values[ProtocolVersion.V4], - }, - }) - return acc - }, [] as StackedHistogramData[]) - - const dataQuality = checkDataQuality(entries, ChartType.VOLUME, duration) - return { chartType: ChartType.VOLUME, entries, loading, dataQuality } - }, [duration, loading, queryData?.v2HistoricalProtocolVolume, queryData?.v3HistoricalProtocolVolume]) -} - -export function useDailyProtocolTVL(chain: Chain): ChartQueryResult { - const isWindowVisible = useIsWindowVisible() - const isRestExploreEnabled = useFeatureFlag(FeatureFlags.RestExplore) - const { data: queryData, loading } = useDailyProtocolTvlQuery({ - variables: { chain }, - skip: !isWindowVisible || isRestExploreEnabled, - }) - - return useMemo(() => { - const dataByTime = mapDataByTimestamp(queryData?.v2DailyProtocolTvl, queryData?.v3DailyProtocolTvl) - const entries = Object.entries(dataByTime).map(([timestamp, values]) => ({ - time: Number(timestamp), - values: [values[ProtocolVersion.V2], values[ProtocolVersion.V3]], - })) as StackedLineData[] - - const dataQuality = checkDataQuality(entries, ChartType.TVL, HistoryDuration.Year) - return { chartType: ChartType.TVL, entries, loading, dataQuality } - }, [loading, queryData?.v2DailyProtocolTvl, queryData?.v3DailyProtocolTvl]) -} diff --git a/apps/web/src/graphql/data/types.ts b/apps/web/src/graphql/data/types.ts index 923e14b8ef0..baea02ef257 100644 --- a/apps/web/src/graphql/data/types.ts +++ b/apps/web/src/graphql/data/types.ts @@ -1,14 +1,15 @@ import { isSupportedChainId } from 'constants/chains' -import { fiatOnRampToCurrency, gqlToCurrency } from 'graphql/data/util' -import { COMMON_BASES, buildCurrencyInfo } from 'uniswap/src/constants/routing' +import { PricePoint, fiatOnRampToCurrency, gqlToCurrency } from 'graphql/data/util' +import { COMMON_BASES, buildPartialCurrencyInfo } from 'uniswap/src/constants/routing' import { USDC_OPTIMISM } from 'uniswap/src/constants/tokens' import { Token as GqlToken, ProtectionResult, SafetyLevel, + TopTokens100Query, } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' import { CurrencyInfo, TokenList } from 'uniswap/src/features/dataApi/types' -import { getCurrencySafetyInfo } from 'uniswap/src/features/dataApi/utils' +import { buildCurrencyInfo, getCurrencySafetyInfo } from 'uniswap/src/features/dataApi/utils' import { FORSupportedToken } from 'uniswap/src/features/fiatOnRamp/types' import { UniverseChainId } from 'uniswap/src/types/chains' import { isSameAddress } from 'utilities/src/addresses' @@ -28,14 +29,14 @@ export function gqlTokenToCurrencyInfo(token?: GqlToken): CurrencyInfo | undefin return undefined } - const currencyInfo: CurrencyInfo = { + const currencyInfo: CurrencyInfo = buildCurrencyInfo({ currency, currencyId: currencyId(currency), logoUrl: token.project?.logo?.url ?? token.project?.logoUrl, safetyLevel: token.project?.safetyLevel ?? SafetyLevel.StrongWarning, isSpam: token.project?.isSpam ?? false, safetyInfo: getCurrencySafetyInfo(token.project?.safetyLevel, token.protectionInfo), - } + }) return currencyInfo } @@ -63,14 +64,14 @@ export function meldSupportedCurrencyToCurrencyInfo(forCurrency: FORSupportedTok // Special case for *bridged* USDC on Optimism, which we otherwise don't use in our app. if (isSameAddress(forCurrency.address, '0x7f5c764cbc14f9669b88837ca1490cca17c31607')) { - return buildCurrencyInfo(USDC_OPTIMISM) + return buildPartialCurrencyInfo(USDC_OPTIMISM) } const currency = fiatOnRampToCurrency(forCurrency) if (!currency) { return undefined } - return { + return buildCurrencyInfo({ currency, currencyId: currencyId(currency), logoUrl: forCurrency.symbol, @@ -80,5 +81,8 @@ export function meldSupportedCurrencyToCurrencyInfo(forCurrency: FORSupportedTok protectionResult: ProtectionResult.Benign, }, isSpam: false, - } + }) } + +export type SparklineMap = { [key: string]: PricePoint[] | undefined } +export type TopToken = NonNullable['topTokens']>[number] diff --git a/apps/web/src/graphql/data/util.tsx b/apps/web/src/graphql/data/util.tsx index 1957b9fe1ed..b6b24c7bec6 100644 --- a/apps/web/src/graphql/data/util.tsx +++ b/apps/web/src/graphql/data/util.tsx @@ -1,5 +1,5 @@ -import { OperationVariables, QueryResult } from '@apollo/client' import { DeepPartial } from '@apollo/client/utilities' +import { BigNumber } from '@ethersproject/bignumber' import { DataTag, DefaultError, QueryKey, UndefinedInitialDataOptions, queryOptions } from '@tanstack/react-query' import { Currency, Token } from '@uniswap/sdk-core' import { @@ -16,7 +16,6 @@ import { NATIVE_CHAIN_ID } from 'constants/tokens' import { DefaultTheme } from 'lib/styled-components' import ms from 'ms' import { ExploreTab } from 'pages/Explore' -import { useEffect } from 'react' import { TokenStat } from 'state/explore/types' import { ThemeColors } from 'theme/colors' import { GQL_MAINNET_CHAINS, UNIVERSE_CHAIN_INFO } from 'uniswap/src/constants/chains' @@ -40,21 +39,6 @@ export enum PollingInterval { LightningMcQueen = ms(`3s`), // approx block interval for polygon } -// Polls a query only when the current component is mounted, as useQuery's pollInterval prop will continue to poll after unmount -export function usePollQueryWhileMounted( - queryResult: QueryResult, - interval: PollingInterval, -) { - const { startPolling, stopPolling } = queryResult - - useEffect(() => { - startPolling(interval) - return stopPolling - }, [interval, startPolling, stopPolling]) - - return queryResult -} - export enum TimePeriod { HOUR = 'H', DAY = 'D', @@ -81,10 +65,6 @@ export function toHistoryDuration(timePeriod: TimePeriod): HistoryDuration { export type PricePoint = { timestamp: number; value: number } -export function isPricePoint(p: PricePoint | undefined): p is PricePoint { - return p !== undefined -} - export function isGqlSupportedChain(chainId?: UniverseChainId) { return !!chainId && GQL_MAINNET_CHAINS.includes(UNIVERSE_CHAIN_INFO[chainId].backendChain.chain) } @@ -111,6 +91,9 @@ export function gqlToCurrency(token: DeepPartial): Currenc token.decimals ?? 18, token.symbol ?? undefined, token.name ?? token.project?.name ?? undefined, + undefined, + token.feeData?.buyFeeBps ? BigNumber.from(token.feeData.buyFeeBps) : undefined, + token.feeData?.sellFeeBps ? BigNumber.from(token.feeData.sellFeeBps) : undefined, ) } } diff --git a/apps/web/src/hooks/useContract.ts b/apps/web/src/hooks/useContract.ts index 0e3a34ebcf0..c5cccd0c3ea 100644 --- a/apps/web/src/hooks/useContract.ts +++ b/apps/web/src/hooks/useContract.ts @@ -2,6 +2,7 @@ import { Contract } from '@ethersproject/contracts' import { InterfaceEventName } from '@uniswap/analytics-events' import { ARGENT_WALLET_DETECTOR_ADDRESS, + CHAIN_TO_ADDRESSES_MAP, ENS_REGISTRAR_ADDRESSES, MULTICALL_ADDRESSES, NONFUNGIBLE_POSITION_MANAGER_ADDRESSES, @@ -177,12 +178,17 @@ export function useMainnetInterfaceMulticall() { ) as UniswapInterfaceMulticall } -export function useV3NFTPositionManagerContract(withSignerIfPossible?: boolean): NonfungiblePositionManager | null { +export function useV3NFTPositionManagerContract( + withSignerIfPossible?: boolean, + chainId?: UniverseChainId, +): NonfungiblePositionManager | null { const account = useAccount() + const chainIdToUse = chainId ?? account.chainId const contract = useContract( - account.chainId ? NONFUNGIBLE_POSITION_MANAGER_ADDRESSES[account.chainId] : undefined, + chainIdToUse ? NONFUNGIBLE_POSITION_MANAGER_ADDRESSES[chainIdToUse] : undefined, NFTPositionManagerABI, withSignerIfPossible, + chainIdToUse, ) useEffect(() => { if (contract && account.isConnected) { @@ -192,10 +198,43 @@ export function useV3NFTPositionManagerContract(withSignerIfPossible?: boolean): name: 'V3NonfungiblePositionManager', address: contract.address, withSignerIfPossible, - chainId: account.chainId, + chainId: chainIdToUse, + }, + }) + } + }, [account.isConnected, chainIdToUse, contract, withSignerIfPossible]) + return contract +} + +/** + * NOTE: the return type of this contract and the ABI used are just a generic ERC721, + * so you can only use this to call tokenURI or other Position NFT related functions. + */ +export function useV4NFTPositionManagerContract( + withSignerIfPossible?: boolean, + chainId?: UniverseChainId, +): Erc721 | null { + const account = useAccount() + const chainIdToUse = chainId ?? account.chainId + + const contract = useContract( + chainIdToUse ? CHAIN_TO_ADDRESSES_MAP[chainIdToUse].v4PositionManagerAddress : undefined, + NFTPositionManagerABI, + withSignerIfPossible, + chainIdToUse, + ) + useEffect(() => { + if (contract && account.isConnected) { + sendAnalyticsEvent(InterfaceEventName.WALLET_PROVIDER_USED, { + source: 'useV4NFTPositionManagerContract', + contract: { + name: 'V4NonfungiblePositionManager', + address: contract.address, + withSignerIfPossible, + chainId: chainIdToUse, }, }) } - }, [account.isConnected, account.chainId, contract, withSignerIfPossible]) + }, [account.isConnected, chainIdToUse, contract, withSignerIfPossible]) return contract } diff --git a/apps/web/src/hooks/usePositionTokenURI.ts b/apps/web/src/hooks/usePositionTokenURI.ts index e3216ce9de3..8cf210413fe 100644 --- a/apps/web/src/hooks/usePositionTokenURI.ts +++ b/apps/web/src/hooks/usePositionTokenURI.ts @@ -1,8 +1,15 @@ import { BigNumber } from '@ethersproject/bignumber' -import { useV3NFTPositionManagerContract } from 'hooks/useContract' +// eslint-disable-next-line no-restricted-imports +import { ProtocolVersion } from '@uniswap/client-pools/dist/pools/v1/types_pb' +import { useV3NFTPositionManagerContract, useV4NFTPositionManagerContract } from 'hooks/useContract' +import { useEthersProvider } from 'hooks/useEthersProvider' import JSBI from 'jsbi' -import { NEVER_RELOAD, useSingleCallResult } from 'lib/hooks/multicall' +import { NEVER_RELOAD } from 'lib/hooks/multicall' +import multicall from 'lib/state/multicall' import { useMemo } from 'react' +import { Erc721 } from 'uniswap/src/abis/types/Erc721' +import { NonfungiblePositionManager } from 'uniswap/src/abis/types/v3/NonfungiblePositionManager' +import { UniverseChainId } from 'uniswap/src/types/chains' type TokenId = number | JSBI | BigNumber @@ -27,16 +34,37 @@ type UsePositionTokenURIResult = loading: true } -export function usePositionTokenURI(tokenId: TokenId | undefined): UsePositionTokenURIResult { - const contract = useV3NFTPositionManagerContract() +function useNFTPositionManagerContract( + version: ProtocolVersion, + chainId?: UniverseChainId, +): NonfungiblePositionManager | Erc721 | null { + const v3Contract = useV3NFTPositionManagerContract(false, chainId) + const v4Contract = useV4NFTPositionManagerContract(false, chainId) + return version === ProtocolVersion.V3 ? v3Contract : v4Contract +} + +export function usePositionTokenURI( + tokenId: TokenId | undefined, + chainId?: UniverseChainId, + version?: ProtocolVersion, +): UsePositionTokenURIResult { + const contract = useNFTPositionManagerContract(version ?? ProtocolVersion.V3, chainId) const inputs = useMemo( () => [tokenId instanceof BigNumber ? tokenId.toHexString() : tokenId?.toString(16)], [tokenId], ) - const { result, error, loading, valid } = useSingleCallResult(contract, 'tokenURI', inputs, { - ...NEVER_RELOAD, - gasRequired: 3_000_000, - }) + const latestBlock = useEthersProvider({ chainId })?.blockNumber + const { result, error, loading, valid } = multicall.hooks.useSingleCallResult( + chainId, + latestBlock, + contract, + 'tokenURI', + inputs, + { + ...NEVER_RELOAD, + gasRequired: 3_000_000, + }, + ) return useMemo(() => { if (error || !valid || !tokenId) { diff --git a/apps/web/src/lib/hooks/useTokenList/sorting.test.ts b/apps/web/src/lib/hooks/useTokenList/sorting.test.ts index 8967df1963b..ebed74f3cc1 100644 --- a/apps/web/src/lib/hooks/useTokenList/sorting.test.ts +++ b/apps/web/src/lib/hooks/useTokenList/sorting.test.ts @@ -99,7 +99,7 @@ const tokens: TokenBalance[] = [ describe('sorting', () => { describe('getSortedPortfolioTokens', () => { it('should return an empty array if portfolioTokenBalances is undefined', () => { - const result = getSortedPortfolioTokens(undefined, {}, UniverseChainId.Mainnet) + const result = getSortedPortfolioTokens(undefined, {}, UniverseChainId.Mainnet, { isTestnetModeEnabled: false }) expect(result).toEqual([]) }) it('should return only visible tokens, sorted by balances', () => { @@ -111,6 +111,7 @@ describe('sorting', () => { [USDT.address]: { usdValue: 100, balance: 100 }, }, UniverseChainId.Mainnet, + { isTestnetModeEnabled: false }, ) expect(result).toEqual([nativeOnChain(UniverseChainId.Mainnet), USDT, WBTC]) diff --git a/apps/web/src/lib/hooks/useTokenList/sorting.ts b/apps/web/src/lib/hooks/useTokenList/sorting.ts index 0bc0d8523bf..0cdbda776e6 100644 --- a/apps/web/src/lib/hooks/useTokenList/sorting.ts +++ b/apps/web/src/lib/hooks/useTokenList/sorting.ts @@ -43,7 +43,7 @@ export function getSortedPortfolioTokens( portfolioTokenBalances: readonly (PortfolioBalance | undefined)[] | undefined, balances: TokenBalances, chainId: UniverseChainId | undefined, - splitOptions?: SplitOptions, + splitOptions: SplitOptions, ): Token[] { const validVisiblePortfolioTokens = splitHiddenTokens(portfolioTokenBalances ?? [], splitOptions) .visibleTokens.map((tokenBalance) => { diff --git a/apps/web/src/lib/utils/searchBar.test.ts b/apps/web/src/lib/utils/searchBar.test.ts index db0a3f92784..c1c24b9f484 100644 --- a/apps/web/src/lib/utils/searchBar.test.ts +++ b/apps/web/src/lib/utils/searchBar.test.ts @@ -3,17 +3,20 @@ import { searchTokenToTokenSearchResult } from 'lib/utils/searchBar' import { getNativeAddress } from 'uniswap/src/constants/addresses' import { Chain, + ProtectionResult, SafetyLevel, TokenStandard, } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' -import { SearchResultType } from 'uniswap/src/features/search/SearchResult' +import { TokenList } from 'uniswap/src/features/dataApi/types' +import { getCurrencySafetyInfo } from 'uniswap/src/features/dataApi/utils' +import { SearchResultType, TokenSearchResult } from 'uniswap/src/features/search/SearchResult' import { UniverseChainId } from 'uniswap/src/types/chains' describe('searchBar', () => { describe('searchTokenToTokenSearchResult', () => { describe(`${NATIVE_CHAIN_ID}`, () => { it('accepts a searchToken and returns a TokenSearchResult', () => { - const ethSearchResult = { + const ethSearchResult: TokenSearchResult = { type: SearchResultType.Token, chainId: UniverseChainId.Mainnet, address: null, @@ -21,6 +24,8 @@ describe('searchBar', () => { symbol: 'ETH', name: 'Ethereum', safetyLevel: SafetyLevel.Verified, + safetyInfo: getCurrencySafetyInfo(SafetyLevel.Verified, { attackTypes: [], result: ProtectionResult.Benign }), + feeData: null, } expect( @@ -39,24 +44,10 @@ describe('searchBar', () => { logoUrl: 'eth-logo.png', safetyLevel: SafetyLevel.Verified, }, - }), - ).toEqual(ethSearchResult) - - expect( - searchTokenToTokenSearchResult({ - decimals: 18, - name: 'Ethereum', - chain: Chain.Ethereum, - // This is not a mistake, sometimes the standard for ETH is ERC20 - // in search results. - standard: TokenStandard.Erc20, - address: NATIVE_CHAIN_ID, - symbol: 'ETH', - chainId: UniverseChainId.Mainnet, - // @ts-ignore - project: { - logoUrl: 'eth-logo.png', - safetyLevel: SafetyLevel.Verified, + feeData: undefined, + protectionInfo: { + attackTypes: [], + result: ProtectionResult.Benign, }, }), ).toEqual(ethSearchResult) @@ -75,6 +66,11 @@ describe('searchBar', () => { logoUrl: 'matic-logo.png', safetyLevel: SafetyLevel.Verified, }, + feeData: undefined, + protectionInfo: { + attackTypes: [], + result: ProtectionResult.Benign, + }, }), ).toEqual({ type: SearchResultType.Token, @@ -84,12 +80,18 @@ describe('searchBar', () => { symbol: 'MATIC', name: 'Polygon', safetyLevel: SafetyLevel.Verified, - }) + feeData: null, + safetyInfo: { + tokenList: TokenList.Default, + attackType: undefined, + protectionResult: ProtectionResult.Benign, + }, + } as TokenSearchResult) }) }) describe(`${TokenStandard.Erc20}`, () => { it('accepts a searchToken and returns a TokenSearchResult', () => { - const tokenSearchResult = { + const tokenSearchResult: TokenSearchResult = { type: SearchResultType.Token, chainId: 1, address: '0x123', @@ -97,6 +99,12 @@ describe('searchBar', () => { symbol: 'ABC', name: 'ABC Token', safetyLevel: SafetyLevel.Verified, + feeData: null, + safetyInfo: { + tokenList: TokenList.Default, + attackType: undefined, + protectionResult: ProtectionResult.Benign, + }, } expect( @@ -113,6 +121,11 @@ describe('searchBar', () => { logoUrl: 'token-logo.png', safetyLevel: SafetyLevel.Verified, }, + feeData: undefined, + protectionInfo: { + attackTypes: [], + result: ProtectionResult.Benign, + }, }), ).toEqual(tokenSearchResult) }) diff --git a/apps/web/src/lib/utils/searchBar.ts b/apps/web/src/lib/utils/searchBar.ts index 16cc51a8bf7..76af2e715b8 100644 --- a/apps/web/src/lib/utils/searchBar.ts +++ b/apps/web/src/lib/utils/searchBar.ts @@ -1,5 +1,6 @@ import { GqlSearchToken } from 'graphql/data/SearchTokens' import { GenieCollection } from 'nft/types' +import { getCurrencySafetyInfo } from 'uniswap/src/features/dataApi/utils' import { NFTCollectionSearchResult, SearchResultType, @@ -38,6 +39,8 @@ export const searchTokenToTokenSearchResult = ( name: searchToken.name ?? null, logoUrl: searchToken.project?.logoUrl ?? null, safetyLevel: searchToken.project?.safetyLevel ?? null, + safetyInfo: getCurrencySafetyInfo(searchToken.project?.safetyLevel, searchToken.protectionInfo), + feeData: searchToken.feeData ?? null, } } diff --git a/apps/web/src/pages/AddLiquidity/redirects.tsx b/apps/web/src/pages/AddLiquidity/redirects.tsx index b4cca59eb0f..4e5c222607f 100644 --- a/apps/web/src/pages/AddLiquidity/redirects.tsx +++ b/apps/web/src/pages/AddLiquidity/redirects.tsx @@ -2,12 +2,20 @@ import { useAccount } from 'hooks/useAccount' import AddLiquidity from 'pages/AddLiquidity/index' import { Navigate, useParams } from 'react-router-dom' import { WRAPPED_NATIVE_CURRENCY } from 'uniswap/src/constants/tokens' +import { FeatureFlags } from 'uniswap/src/features/gating/flags' +import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' export default function AddLiquidityWithTokenRedirects() { + const isV4EverywhereEnabled = useFeatureFlag(FeatureFlags.V4Everywhere) const { currencyIdA, currencyIdB } = useParams<{ currencyIdA: string; currencyIdB: string; feeAmount?: string }>() const { chainId } = useAccount() + if (isV4EverywhereEnabled) { + // TODO(WEB-5361): update this to enable prefilling form from URL currencyIdA and currencyIdB + return + } + // prevent weth + eth const isETHOrWETHA = currencyIdA === 'ETH' || (chainId !== undefined && currencyIdA === WRAPPED_NATIVE_CURRENCY[chainId]?.address) diff --git a/apps/web/src/pages/AddLiquidityV2/redirects.tsx b/apps/web/src/pages/AddLiquidityV2/redirects.tsx index cc62e8e7758..cbd9bcd18e3 100644 --- a/apps/web/src/pages/AddLiquidityV2/redirects.tsx +++ b/apps/web/src/pages/AddLiquidityV2/redirects.tsx @@ -1,9 +1,15 @@ import AddLiquidityV2 from 'pages/AddLiquidityV2/index' import { Navigate, useParams } from 'react-router-dom' +import { FeatureFlags } from 'uniswap/src/features/gating/flags' +import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' export default function AddLiquidityV2WithTokenRedirects() { + const isV4EverywhereEnabled = useFeatureFlag(FeatureFlags.V4Everywhere) const { currencyIdA, currencyIdB } = useParams<{ currencyIdA: string; currencyIdB: string }>() - + if (isV4EverywhereEnabled) { + // TODO(WEB-5361): update this to enable prefilling form from URL currencyIdA and currencyIdB + return + } if (currencyIdA && currencyIdB && currencyIdA.toLowerCase() === currencyIdB.toLowerCase()) { return } diff --git a/apps/web/src/pages/Explore/charts/ExploreChartsSection.tsx b/apps/web/src/pages/Explore/charts/ExploreChartsSection.tsx index 0a4756e2a62..5670905f413 100644 --- a/apps/web/src/pages/Explore/charts/ExploreChartsSection.tsx +++ b/apps/web/src/pages/Explore/charts/ExploreChartsSection.tsx @@ -9,9 +9,7 @@ import { getCumulativeSum, getCumulativeVolume, getVolumeProtocolInfo } from 'co import { ChartType } from 'components/Charts/utils' import { DataQuality } from 'components/Tokens/TokenDetails/ChartSection/util' import { MAX_WIDTH_MEDIA_BREAKPOINT } from 'components/Tokens/constants' -import { chainIdToBackendChain, useChainFromUrlParam } from 'constants/chains' -import { useDailyProtocolTVL, useHistoricalProtocolVolume } from 'graphql/data/protocolStats' -import { TimePeriod, getProtocolColor, getProtocolGradient, getSupportedGraphQlChain } from 'graphql/data/util' +import { TimePeriod, getProtocolColor, getProtocolGradient } from 'graphql/data/util' import { useScreenSize } from 'hooks/screenSize/useScreenSize' import { useAtomValue } from 'jotai/utils' import { useTheme } from 'lib/styled-components' @@ -23,10 +21,7 @@ import { import { EllipsisTamaguiStyle } from 'theme/components' import { Flex, SegmentedControl, Text, styled } from 'ui/src' import { HistoryDuration, PriceSource } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag, useFeatureFlagWithLoading } from 'uniswap/src/features/gating/hooks' import { Trans } from 'uniswap/src/i18n' -import { UniverseChainId } from 'uniswap/src/types/chains' import { NumberType, useFormatter } from 'utils/formatNumbers' const EXPLORE_CHART_HEIGHT_PX = 368 @@ -70,14 +65,10 @@ const SectionTitle = styled(Text, { lineHeight: 24, }) -function VolumeChartSection({ chainId }: { chainId: UniverseChainId }) { +function VolumeChartSection() { const [timePeriod, setTimePeriod] = useState(TimePeriod.DAY) const theme = useTheme() const isSmallScreen = !useScreenSize()['sm'] - const { value: isMultichainExploreEnabledLoaded, isLoading: isMultichainExploreLoading } = useFeatureFlagWithLoading( - FeatureFlags.MultichainExplore, - ) - const isMultichainExploreEnabled = isMultichainExploreEnabledLoaded || isMultichainExploreLoading const refitChartContent = useAtomValue(refitChartContentAtom) function timeGranularityToHistoryDuration(timePeriod: TimePeriod): HistoryDuration { @@ -94,43 +85,25 @@ function VolumeChartSection({ chainId }: { chainId: UniverseChainId }) { } } - const { - entries: gqlEntries, - loading: gqlLoading, - dataQuality: gqlDataQuality, - } = useHistoricalProtocolVolume( - chainIdToBackendChain({ chainId, withFallback: true }), - isSmallScreen ? HistoryDuration.Month : timeGranularityToHistoryDuration(timePeriod), - ) - const { - entries: restEntries, - loading: restLoading, - dataQuality: restDataQuality, - } = useRestHistoricalProtocolVolume( + const { entries, loading, dataQuality } = useRestHistoricalProtocolVolume( isSmallScreen ? HistoryDuration.Month : timeGranularityToHistoryDuration(timePeriod), ) - const isRestExploreEnabled = useFeatureFlag(FeatureFlags.RestExplore) - const { entries, loading, dataQuality } = isRestExploreEnabled - ? { entries: restEntries, loading: restLoading, dataQuality: restDataQuality } - : { entries: gqlEntries, loading: gqlLoading, dataQuality: gqlDataQuality } const params = useMemo<{ data: StackedHistogramData[] colors: [string, string] useThinCrosshair: boolean headerHeight: number - isMultichainExploreEnabled: boolean background: string }>( () => ({ data: entries, colors: [theme.accent1, theme.accent3], - headerHeight: isMultichainExploreEnabled ? 0 : 80, + headerHeight: 0, stale: dataQuality === DataQuality.STALE, - useThinCrosshair: isMultichainExploreEnabled, - isMultichainExploreEnabled, + useThinCrosshair: true, background: theme.background, }), - [entries, theme.accent1, theme.accent3, theme.background, isMultichainExploreEnabled, dataQuality], + [entries, theme.accent1, theme.accent3, theme.background, dataQuality], ) const cumulativeVolume = useMemo(() => getCumulativeVolume(entries), [entries]) @@ -194,30 +167,18 @@ function VolumeChartSection({ chainId }: { chainId: UniverseChainId }) { ) } -function TVLChartSection({ chainId }: { chainId: UniverseChainId }) { +function TVLChartSection() { const theme = useTheme() - const isMultichainExploreEnabled = useFeatureFlag(FeatureFlags.MultichainExplore) - const { - entries: gqlEntries, - loading: gqlLoading, - dataQuality: gqlDataQuality, - } = useDailyProtocolTVL(chainIdToBackendChain({ chainId })) - const { entries: restEntries, loading: restLoading, dataQuality: restDataQuality } = useRestDailyProtocolTVL() - const isRestExploreEnabled = useFeatureFlag(FeatureFlags.RestExplore) - const { entries, loading, dataQuality } = isRestExploreEnabled - ? { entries: restEntries, loading: restLoading, dataQuality: restDataQuality } - : { entries: gqlEntries, loading: gqlLoading, dataQuality: gqlDataQuality } + const { entries, loading, dataQuality } = useRestDailyProtocolTVL() const lastEntry = entries[entries.length - 1] const params = useMemo( () => ({ data: entries, colors: EXPLORE_PRICE_SOURCES?.map((source) => getProtocolColor(source, theme)) ?? [theme.accent1], - gradients: isMultichainExploreEnabled - ? EXPLORE_PRICE_SOURCES?.map((source) => getProtocolGradient(source)) - : undefined, + gradients: EXPLORE_PRICE_SOURCES?.map((source) => getProtocolGradient(source)), }), - [entries, isMultichainExploreEnabled, theme], + [entries, theme], ) const isSmallScreen = !useScreenSize()['sm'] @@ -273,12 +234,10 @@ function MinimalStatDisplay({ title, value, time }: { title: ReactNode; value: n } export function ExploreChartsSection() { - const chain = getSupportedGraphQlChain(useChainFromUrlParam(), { fallbackToEthereum: true }) - return ( - - + + ) } diff --git a/apps/web/src/pages/Explore/index.tsx b/apps/web/src/pages/Explore/index.tsx index 918e5f69447..1eed5be790b 100644 --- a/apps/web/src/pages/Explore/index.tsx +++ b/apps/web/src/pages/Explore/index.tsx @@ -19,12 +19,8 @@ import { useNavigate } from 'react-router-dom' import { ExploreContextProvider } from 'state/explore' import { TamaguiClickableStyle } from 'theme/components' import { Flex, Text, styled as tamaguiStyled } from 'ui/src' -import { UNIVERSE_CHAIN_INFO } from 'uniswap/src/constants/chains' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import Trace from 'uniswap/src/features/telemetry/Trace' import { Trans } from 'uniswap/src/i18n' -import { UniverseChainId } from 'uniswap/src/types/chains' export enum ExploreTab { Tokens = 'tokens', @@ -93,7 +89,6 @@ const HeaderTab = tamaguiStyled(Text, { const Explore = ({ initialTab }: { initialTab?: ExploreTab }) => { const tabNavRef = useRef(null) const resetManualOutage = useResetAtom(manualChainOutageAtom) - const isMultichainExploreEnabled = useFeatureFlag(FeatureFlags.MultichainExplore) const initialKey: number = useMemo(() => { const key = initialTab && Pages.findIndex((page) => page.key === initialTab) @@ -119,12 +114,8 @@ const Explore = ({ initialTab }: { initialTab?: ExploreTab }) => { const { tab: tabName } = useExploreParams() const tab = tabName ?? ExploreTab.Tokens - const chainWithoutFallback = useChainFromUrlParam() - const chain = useMemo(() => { - return isMultichainExploreEnabled - ? chainWithoutFallback - : chainWithoutFallback ?? UNIVERSE_CHAIN_INFO[UniverseChainId.Mainnet] - }, [chainWithoutFallback, isMultichainExploreEnabled]) + const chain = useChainFromUrlParam() + useEffect(() => { const tabIndex = Pages.findIndex((page) => page.key === tab) if (tabIndex !== -1) { @@ -181,7 +172,7 @@ const Explore = ({ initialTab }: { initialTab?: ExploreTab }) => { > {Pages.map(({ title, loggingElementName, key }, index) => { // disable Transactions tab if no chain is selected - const disabled = isMultichainExploreEnabled && key === ExploreTab.Transactions && !chain + const disabled = key === ExploreTab.Transactions && !chain const url = getTokenExploreURL({ tab: key, chain: chain?.backendChain.chain }) return ( ( } + title={} onDismiss={() => setShowConfirm(false)} topContent={modalHeader} /> @@ -802,7 +802,7 @@ function PositionPageContent() { {fiatValueOfFees?.greaterThan(new Fraction(1, 100)) ? ( @@ -839,7 +839,7 @@ function PositionPageContent() { ) : ( <> - + )} diff --git a/apps/web/src/pages/LegacyPool/redirects.tsx b/apps/web/src/pages/LegacyPool/redirects.tsx new file mode 100644 index 00000000000..a8d07e880b7 --- /dev/null +++ b/apps/web/src/pages/LegacyPool/redirects.tsx @@ -0,0 +1,57 @@ +import { chainIdToBackendChain } from 'constants/chains' +import LegacyPool from 'pages/LegacyPool' +import LegacyPositionPage from 'pages/LegacyPool/PositionPage' +import LegacyPoolV2 from 'pages/LegacyPool/v2' +import PoolFinder from 'pages/PoolFinder' +import { Navigate, useParams, useSearchParams } from 'react-router-dom' +import { FeatureFlags } from 'uniswap/src/features/gating/flags' +import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' +import { UniverseChainId } from 'uniswap/src/types/chains' +import { searchParamToBackendName } from 'utils/chains' +import { useAccount } from 'wagmi' + +// /pool +export function LegacyPoolRedirects() { + const isV4EverywhereEnabled = useFeatureFlag(FeatureFlags.V4Everywhere) + + if (isV4EverywhereEnabled) { + return + } + return +} + +// /pool/v2 +export function LegacyPoolV2Redirects() { + const isV4EverywhereEnabled = useFeatureFlag(FeatureFlags.V4Everywhere) + + if (isV4EverywhereEnabled) { + return + } + return +} + +// /pool/v2/find +export function PoolFinderRedirects() { + const isV4EverywhereEnabled = useFeatureFlag(FeatureFlags.V4Everywhere) + + if (isV4EverywhereEnabled) { + return + } + return +} + +// /pool/:tokenId?chain=... +export function LegacyPositionPageRedirects() { + const isV4EverywhereEnabled = useFeatureFlag(FeatureFlags.V4Everywhere) + const { tokenId } = useParams<{ tokenId: string }>() + const [searchParams] = useSearchParams() + const { chainId: connectedChainId } = useAccount() + + if (isV4EverywhereEnabled) { + const chainName = + searchParamToBackendName(searchParams.get('chain'))?.toLowerCase() ?? + chainIdToBackendChain({ chainId: connectedChainId ?? UniverseChainId.Mainnet }).toLowerCase() + return + } + return +} diff --git a/apps/web/src/pages/MigrateV3/MigrateV3LiquidityTxContext.tsx b/apps/web/src/pages/MigrateV3/MigrateV3LiquidityTxContext.tsx new file mode 100644 index 00000000000..73c3a7caa19 --- /dev/null +++ b/apps/web/src/pages/MigrateV3/MigrateV3LiquidityTxContext.tsx @@ -0,0 +1,206 @@ +// eslint-disable-next-line no-restricted-imports +import { ProtocolVersion } from '@uniswap/client-pools/dist/pools/v1/types_pb' +import { Currency, CurrencyAmount } from '@uniswap/sdk-core' +import { useV3OrV4PositionDerivedInfo } from 'components/Liquidity/hooks' +import { V3PositionInfo } from 'components/Liquidity/types' +import { ZERO_ADDRESS } from 'constants/misc' +import { useCreatePositionContext, usePriceRangeContext } from 'pages/Pool/Positions/create/CreatePositionContext' +import { PropsWithChildren, createContext, useContext, useMemo } from 'react' +import { useCheckLpApprovalQuery } from 'uniswap/src/data/apiClients/tradingApi/useCheckLpApprovalQuery' +import { useMigrateV3LpPositionCalldataQuery } from 'uniswap/src/data/apiClients/tradingApi/useMigrateV3LpPositionCalldataQuery' +import { + CheckApprovalLPRequest, + MigrateLPPositionRequest, + ProtocolItems, +} from 'uniswap/src/data/tradingApi/__generated__' +import { MigrateV3PositionTxAndGasInfo } from 'uniswap/src/features/transactions/liquidity/types' +import { validatePermit, validateTransactionRequest } from 'uniswap/src/features/transactions/swap/utils/trade' +import { ONE_SECOND_MS } from 'utilities/src/time/time' +import { useAccount } from 'wagmi' + +interface MigrateV3PositionTxContextType { + txInfo?: MigrateV3PositionTxAndGasInfo + gasFeeEstimateUSD?: CurrencyAmount +} + +const MigrateV3PositionTxContext = createContext(undefined) + +export function useMigrateV3TxContext() { + const context = useContext(MigrateV3PositionTxContext) + if (!context) { + throw new Error('useMigrateV3TxContext must be used within a MigrateV3PositionTxContextProvider') + } + return context +} + +export function MigrateV3PositionTxContextProvider({ + children, + positionInfo, +}: PropsWithChildren<{ positionInfo: V3PositionInfo }>): JSX.Element { + const account = useAccount() + + const { derivedPositionInfo, positionState } = useCreatePositionContext() + const { feeValue0, feeValue1 } = useV3OrV4PositionDerivedInfo(positionInfo) + const { + derivedPriceRangeInfo, + priceRangeState: { fullRange }, + } = usePriceRangeContext() + + const increaseLiquidityApprovalParams: CheckApprovalLPRequest | undefined = useMemo(() => { + if (!positionInfo || !account.address) { + return undefined + } + return { + simulateTransaction: true, + walletAddress: account.address, + chainId: positionInfo.currency0Amount.currency.chainId, + protocol: ProtocolItems.V3, + positionToken: positionInfo.tokenId, + } + }, [positionInfo, account.address]) + + const { data: migrateTokenApprovals } = useCheckLpApprovalQuery({ + params: increaseLiquidityApprovalParams, + headers: { + 'x-universal-router-version': '2.0', + }, + staleTime: 5 * ONE_SECOND_MS, + }) + + const migratePositionRequestArgs: MigrateLPPositionRequest | undefined = useMemo(() => { + if ( + !derivedPositionInfo || + !positionInfo || + !positionInfo.tokenId || + !account?.address || + !derivedPriceRangeInfo || + derivedPositionInfo.protocolVersion !== ProtocolVersion.V4 || + derivedPriceRangeInfo.protocolVersion !== ProtocolVersion.V4 || + !positionInfo.pool || + !positionInfo.liquidity + ) { + return undefined + } + const destinationPool = derivedPositionInfo.pool ?? derivedPriceRangeInfo.mockPool + if (!destinationPool) { + return undefined + } + const tickLower = fullRange ? derivedPriceRangeInfo.tickSpaceLimits[0] : derivedPriceRangeInfo.ticks?.[0] + const tickUpper = fullRange ? derivedPriceRangeInfo.tickSpaceLimits[1] : derivedPriceRangeInfo.ticks?.[1] + + if (!tickLower || !tickUpper || !positionInfo.pool || !positionInfo.liquidity) { + return undefined + } + return { + inputProtocol: ProtocolItems.V3, + tokenId: Number(positionInfo.tokenId), + inputPosition: { + pool: { + token0: positionInfo.currency0Amount.currency.isNative + ? ZERO_ADDRESS + : positionInfo.currency0Amount.currency.address, + token1: positionInfo.currency1Amount.currency.isNative + ? ZERO_ADDRESS + : positionInfo.currency1Amount.currency.address, + fee: positionInfo.feeTier ? Number(positionInfo.feeTier) : undefined, + tickSpacing: positionInfo?.tickSpacing ? Number(positionInfo?.tickSpacing) : undefined, + }, + tickLower: positionInfo.tickLower ? Number(positionInfo.tickLower) : undefined, + tickUpper: positionInfo.tickUpper ? Number(positionInfo.tickUpper) : undefined, + }, + inputPoolLiquidity: positionInfo.pool.liquidity.toString(), + inputCurrentTick: positionInfo.pool.tickCurrent, + inputSqrtRatioX96: positionInfo.pool.sqrtRatioX96?.toString(), + inputPositionLiquidity: positionInfo.liquidity, + + outputProtocol: ProtocolItems.V4, + outputPosition: { + pool: { + token0: positionInfo.currency0Amount.currency.isNative + ? ZERO_ADDRESS + : positionInfo.currency0Amount.currency.address, + token1: positionInfo.currency1Amount.currency.isNative + ? ZERO_ADDRESS + : positionInfo.currency1Amount.currency.address, + fee: positionState.fee.feeAmount, + hooks: positionState.hook, + tickSpacing: destinationPool.tickSpacing, + }, + tickLower, + tickUpper, + }, + outputPoolLiquidity: derivedPositionInfo.creatingPoolOrPair ? undefined : destinationPool.liquidity.toString(), + outputSqrtRatioX96: derivedPositionInfo.creatingPoolOrPair ? undefined : destinationPool.sqrtRatioX96.toString(), + outputCurrentTick: derivedPositionInfo.creatingPoolOrPair ? undefined : destinationPool.tickCurrent, + + initialPrice: derivedPositionInfo.creatingPoolOrPair ? destinationPool.sqrtRatioX96.toString() : undefined, + + chainId: positionInfo.currency0Amount.currency.chainId, + walletAddress: account.address, + expectedTokenOwed0RawAmount: feeValue0?.quotient.toString() ?? '0', + expectedTokenOwed1RawAmount: feeValue1?.quotient.toString() ?? '0', + amount0: positionInfo.currency0Amount.quotient.toString(), + amount1: positionInfo.currency1Amount.quotient.toString(), + } + }, [ + derivedPositionInfo, + positionInfo, + account, + derivedPriceRangeInfo, + fullRange, + positionState.fee.feeAmount, + positionState.hook, + feeValue0?.quotient, + feeValue1?.quotient, + ]) + + const { data: migrateCalldata } = useMigrateV3LpPositionCalldataQuery({ + params: migratePositionRequestArgs, + staleTime: 5 * ONE_SECOND_MS, + }) + + const validatedValue: MigrateV3PositionTxAndGasInfo | undefined = useMemo(() => { + if (!migrateCalldata) { + return undefined + } + + const validatedPermitRequest = validatePermit(migrateTokenApprovals?.permitData) + if (migrateTokenApprovals?.permitData && !validatedPermitRequest) { + return undefined + } + + const txRequest = validateTransactionRequest(migrateCalldata.migrate) + if (!txRequest) { + return undefined + } + + return { + type: 'migrate', + unsigned: Boolean(migrateTokenApprovals?.permitData), + migratePositionRequestArgs, + approveToken0Request: undefined, + approveToken1Request: undefined, + permit: validatedPermitRequest, + protocolVersion: ProtocolVersion.V3, + approvePositionTokenRequest: undefined, + revocationTxRequest: undefined, + txRequest, + action: { + currency0Amount: positionInfo.currency0Amount, + currency1Amount: positionInfo.currency1Amount, + }, + } + }, [ + migrateCalldata, + migratePositionRequestArgs, + migrateTokenApprovals, + positionInfo.currency0Amount, + positionInfo.currency1Amount, + ]) + + return ( + + {children} + + ) +} diff --git a/apps/web/src/pages/MigrateV3/index.tsx b/apps/web/src/pages/MigrateV3/index.tsx index 4d7729db2b0..06c32a83b6c 100644 --- a/apps/web/src/pages/MigrateV3/index.tsx +++ b/apps/web/src/pages/MigrateV3/index.tsx @@ -1,29 +1,54 @@ // eslint-disable-next-line no-restricted-imports import { ProtocolVersion } from '@uniswap/client-pools/dist/pools/v1/types_pb' import { BreadcrumbNavContainer, BreadcrumbNavLink } from 'components/BreadcrumbNav' +import { LiquidityModalHeader } from 'components/Liquidity/LiquidityModalHeader' import { LiquidityPositionCard } from 'components/Liquidity/LiquidityPositionCard' +import { TokenInfo } from 'components/Liquidity/TokenInfo' import { PositionInfo } from 'components/Liquidity/types' import { parseRestPosition } from 'components/Liquidity/utils' import { LoadingRows } from 'components/Loader/styled' import { PoolProgressIndicator } from 'components/PoolProgressIndicator/PoolProgressIndicator' -import { CreatePositionContextProvider, PriceRangeContextProvider } from 'pages/Pool/Positions/create/ContextProviders' -import { useCreatePositionContext } from 'pages/Pool/Positions/create/CreatePositionContext' +import { useChainFromUrlParam } from 'constants/chains' +import useSelectChain from 'hooks/useSelectChain' +import { MigrateV3PositionTxContextProvider, useMigrateV3TxContext } from 'pages/MigrateV3/MigrateV3LiquidityTxContext' +import { + CreatePositionContextProvider, + DepositContextProvider, + PriceRangeContextProvider, +} from 'pages/Pool/Positions/create/ContextProviders' +import { + DEFAULT_DEPOSIT_STATE, + DEFAULT_PRICE_RANGE_STATE_POOL_EXISTS, + useCreatePositionContext, + useDepositContext, + usePriceRangeContext, +} from 'pages/Pool/Positions/create/CreatePositionContext' import { EditSelectTokensStep } from 'pages/Pool/Positions/create/EditStep' import { SelectPriceRangeStep } from 'pages/Pool/Positions/create/RangeSelectionStep' import { SelectTokensStep } from 'pages/Pool/Positions/create/SelectTokenStep' -import { PositionFlowStep } from 'pages/Pool/Positions/create/types' +import { DEFAULT_POSITION_STATE, PositionFlowStep } from 'pages/Pool/Positions/create/types' import { LoadingRow } from 'pages/Pool/Positions/shared' -import { useMemo } from 'react' +import { useMemo, useState } from 'react' import { ChevronRight } from 'react-feather' +import { useDispatch } from 'react-redux' import { Navigate, useParams } from 'react-router-dom' +import { liquiditySaga } from 'state/sagas/liquidity/liquiditySaga' import { ClickableTamaguiStyle } from 'theme/components' import { PositionField } from 'types/position' -import { Flex, Main, Text, styled } from 'ui/src' +import { Flex, Main, Text, styled, useMedia } from 'ui/src' import { ArrowDown } from 'ui/src/components/icons/ArrowDown' import { RotateLeft } from 'ui/src/components/icons/RotateLeft' +import { ProgressIndicator } from 'uniswap/src/components/ConfirmSwapModal/ProgressIndicator' +import { Modal } from 'uniswap/src/components/modals/Modal' +import { useAccountMeta } from 'uniswap/src/contexts/UniswapContext' import { useGetPositionQuery } from 'uniswap/src/data/rest/getPosition' +import { AccountType } from 'uniswap/src/features/accounts/types' import { FeatureFlags } from 'uniswap/src/features/gating/flags' import { useFeatureFlagWithLoading } from 'uniswap/src/features/gating/hooks' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { isValidLiquidityTxContext } from 'uniswap/src/features/transactions/liquidity/types' +import { useUSDCValue } from 'uniswap/src/features/transactions/swap/hooks/useUSDCPrice' +import { TransactionStep } from 'uniswap/src/features/transactions/swap/types/steps' import { Trans, useTranslation } from 'uniswap/src/i18n' import { useAccount } from 'wagmi' @@ -40,13 +65,34 @@ const BodyWrapper = styled(Main, { }) function MigrateV3Inner({ positionInfo }: { positionInfo: PositionInfo }) { - const { positionId } = useParams<{ positionId: string }>() + const { chainName, tokenId } = useParams<{ tokenId: string; chainName: string }>() + const { t } = useTranslation() - const { step, setStep } = useCreatePositionContext() + const { positionState, setPositionState, setStep, step } = useCreatePositionContext() + const { protocolVersion } = positionState + const { setPriceRangeState } = usePriceRangeContext() + const { setDepositState } = useDepositContext() const { value: v4Enabled, isLoading: isV4GateLoading } = useFeatureFlagWithLoading(FeatureFlags.V4Everywhere) + const [transactionSteps, setTransactionSteps] = useState([]) + const [currentTransactionStep, setCurrentTransactionStep] = useState< + { step: TransactionStep; accepted: boolean } | undefined + >() + const selectChain = useSelectChain() + const startChainId = useAccount().chainId + const account = useAccountMeta() + const dispatch = useDispatch() + const { txInfo } = useMigrateV3TxContext() + const media = useMedia() + + const onClose = () => { + setCurrentTransactionStep(undefined) + } + const { currency0Amount, currency1Amount } = positionInfo + const currency0FiatAmount = useUSDCValue(currency0Amount) ?? undefined + const currency1FiatAmount = useUSDCValue(currency1Amount) ?? undefined if (!isV4GateLoading && !v4Enabled) { return @@ -57,33 +103,20 @@ function MigrateV3Inner({ positionInfo }: { positionInfo: PositionInfo }) { } return ( - - - {/* nav breadcrumbs */} - - - - - - - {currency0Amount.currency.symbol} / {currency1Amount.currency.symbol} - - - - - - - - - - - {/* TODO: replace with Spore button once available */} + <> + + + + + + + {currency0Amount.currency.symbol} / {currency1Amount.currency.symbol} + + + + + + { + setPositionState({ ...DEFAULT_POSITION_STATE, protocolVersion }) + setPriceRangeState(DEFAULT_PRICE_RANGE_STATE_POOL_EXISTS) + setDepositState(DEFAULT_DEPOSIT_STATE) setStep(PositionFlowStep.SELECT_TOKENS_AND_FEE_TIER) - // reset any other state here. }} > @@ -105,36 +140,81 @@ function MigrateV3Inner({ positionInfo }: { positionInfo: PositionInfo }) { - - - - + + {!media.xl && ( + + + + )} + + + + + + + + {step === PositionFlowStep.SELECT_TOKENS_AND_FEE_TIER ? ( + { + setStep(PositionFlowStep.PRICE_RANGE) + }} + /> + ) : ( + + )} + {step === PositionFlowStep.PRICE_RANGE && ( + { + const isValidTx = isValidLiquidityTxContext(txInfo) + if (!account || account?.type !== AccountType.SignerMnemonic || !isValidTx) { + return + } + dispatch( + liquiditySaga.actions.trigger({ + selectChain, + startChainId, + account, + liquidityTxContext: txInfo, + setCurrentStep: setCurrentTransactionStep, + setSteps: setTransactionSteps, + onSuccess: onClose, + onFailure: onClose, + }), + ) + }} + /> + )} - - {step === PositionFlowStep.SELECT_TOKENS_AND_FEE_TIER ? ( - { - setStep(PositionFlowStep.PRICE_RANGE) - }} - /> - ) : ( - - )} - {step === PositionFlowStep.PRICE_RANGE && ( - { - // TODO (WEB-4920): submit the migration transaction. - }} - /> - )} - + + + + + + + {t('common.and')} + + + + + + ) } @@ -143,6 +223,7 @@ function MigrateV3Inner({ positionInfo }: { positionInfo: PositionInfo }) { */ export default function MigrateV3() { const { tokenId } = useParams<{ tokenId: string }>() + const chainInfo = useChainFromUrlParam() const account = useAccount() const { data, isLoading: positionLoading } = useGetPositionQuery( account.address @@ -150,14 +231,17 @@ export default function MigrateV3() { owner: account.address, protocolVersion: ProtocolVersion.V3, tokenId, - chainId: account.chainId, + chainId: chainInfo?.id ?? account.chainId, } : undefined, ) + const position = data?.position + const positionInfo = useMemo(() => parseRestPosition(position), [position]) - if (positionLoading || !position || !positionInfo) { + // TODO (WEB-4920): show error state for non-v3 position here. + if (positionLoading || !position || !positionInfo || positionInfo.version !== ProtocolVersion.V3) { return ( @@ -188,7 +272,11 @@ export default function MigrateV3() { }} > - + + + + + ) diff --git a/apps/web/src/pages/NotFound/index.tsx b/apps/web/src/pages/NotFound/index.tsx index 30f1e4109e4..036364107b3 100644 --- a/apps/web/src/pages/NotFound/index.tsx +++ b/apps/web/src/pages/NotFound/index.tsx @@ -4,6 +4,7 @@ import lightImage from 'assets/images/404-page-light.png' import { SmallButtonPrimary } from 'components/Button/buttons' import { useIsMobile } from 'hooks/screenSize/useIsMobile' import styled from 'lib/styled-components' +import { ReactNode } from 'react' import { Link } from 'react-router-dom' import { ThemedText } from 'theme/components' import { useIsDarkMode } from 'theme/components/ThemeToggle' @@ -37,7 +38,13 @@ const PageWrapper = styled(Container)` } ` -export default function NotFound() { +interface NotFoundProps { + title?: ReactNode + subtitle?: ReactNode + actionButton?: ReactNode +} + +export default function NotFound({ title, subtitle, actionButton }: NotFoundProps) { const isDarkMode = useIsDarkMode() const isMobile = useIsMobile() @@ -49,16 +56,20 @@ export default function NotFound() {
- 404 - - - + {title ?? 404} + {subtitle ?? ( + + + + )} Liluni
- - - + {actionButton ?? ( + + + + )}
) diff --git a/apps/web/src/pages/Pool/Positions/ClaimFeeModal.tsx b/apps/web/src/pages/Pool/Positions/ClaimFeeModal.tsx index 5885059aa22..475daa3f69b 100644 --- a/apps/web/src/pages/Pool/Positions/ClaimFeeModal.tsx +++ b/apps/web/src/pages/Pool/Positions/ClaimFeeModal.tsx @@ -1,4 +1,5 @@ /* eslint-disable-next-line no-restricted-imports */ +import { ProtocolVersion } from '@uniswap/client-pools/dist/pools/v1/types_pb' import { Currency, CurrencyAmount } from '@uniswap/sdk-core' import { PositionInfo } from 'components/Liquidity/types' import { getProtocolItems } from 'components/Liquidity/utils' @@ -18,6 +19,7 @@ import { CurrencyLogo } from 'uniswap/src/components/CurrencyLogo/CurrencyLogo' import { Modal } from 'uniswap/src/components/modals/Modal' import { uniswapUrls } from 'uniswap/src/constants/urls' import { useClaimLpFeesCalldataQuery } from 'uniswap/src/data/apiClients/tradingApi/useClaimLpFeesCalldataQuery' +import { ClaimLPFeesRequest } from 'uniswap/src/data/tradingApi/__generated__' import { useLocalizationContext } from 'uniswap/src/features/language/LocalizationContext' import { ModalName } from 'uniswap/src/features/telemetry/constants' import { useTranslation } from 'uniswap/src/i18n' @@ -32,6 +34,7 @@ type ClaimFeeModalProps = { token1Fees?: CurrencyAmount token0FeesUsd?: CurrencyAmount token1FeesUsd?: CurrencyAmount + collectAsWETH: boolean } export function ClaimFeeModal({ @@ -42,6 +45,7 @@ export function ClaimFeeModal({ token1Fees, token0FeesUsd, token1FeesUsd, + collectAsWETH, }: ClaimFeeModalProps) { const { t } = useTranslation() const { formatCurrencyAmount } = useLocalizationContext() @@ -51,35 +55,36 @@ export function ClaimFeeModal({ const dispatch = useAppDispatch() const addTransaction = useTransactionAdder() - const claimLpFeesParams = useMemo(() => { + const claimLpFeesParams = useMemo((): ClaimLPFeesRequest => { return { - params: { - protocol: getProtocolItems(positionInfo.version), - tokenId: positionInfo.tokenId ? Number(positionInfo.tokenId) : undefined, - walletAddress: account.address, - chainId: positionInfo.currency0Amount.currency.chainId, - position: { - pool: { - token0: positionInfo.currency0Amount.currency.isNative - ? ZERO_ADDRESS - : positionInfo.currency0Amount.currency.address, - token1: positionInfo.currency1Amount.currency.isNative - ? ZERO_ADDRESS - : positionInfo.currency1Amount.currency.address, - fee: positionInfo.feeTier ? Number(positionInfo.feeTier) : undefined, - tickSpacing: positionInfo?.tickSpacing ? Number(positionInfo?.tickSpacing) : undefined, - hooks: positionInfo.v4hook, - }, - tickLower: positionInfo.tickLower ? Number(positionInfo.tickLower) : undefined, - tickUpper: positionInfo.tickUpper ? Number(positionInfo.tickUpper) : undefined, + protocol: getProtocolItems(positionInfo.version), + tokenId: positionInfo.tokenId ? Number(positionInfo.tokenId) : undefined, + walletAddress: account.address, + chainId: positionInfo.currency0Amount.currency.chainId, + position: { + pool: { + token0: positionInfo.currency0Amount.currency.isNative + ? ZERO_ADDRESS + : positionInfo.currency0Amount.currency.address, + token1: positionInfo.currency1Amount.currency.isNative + ? ZERO_ADDRESS + : positionInfo.currency1Amount.currency.address, + fee: positionInfo.feeTier ? Number(positionInfo.feeTier) : undefined, + tickSpacing: positionInfo?.tickSpacing ? Number(positionInfo?.tickSpacing) : undefined, + hooks: positionInfo.v4hook, }, - expectedTokenOwed0RawAmount: token0Fees?.quotient.toString(), - expectedTokenOwed1RawAmount: token1Fees?.quotient.toString(), + tickLower: positionInfo.tickLower ? Number(positionInfo.tickLower) : undefined, + tickUpper: positionInfo.tickUpper ? Number(positionInfo.tickUpper) : undefined, }, + expectedTokenOwed0RawAmount: + positionInfo.version !== ProtocolVersion.V4 ? token0Fees?.quotient.toString() : undefined, + expectedTokenOwed1RawAmount: + positionInfo.version !== ProtocolVersion.V4 ? token1Fees?.quotient.toString() : undefined, + collectAsWETH: positionInfo.version !== ProtocolVersion.V4 ? collectAsWETH : undefined, } - }, [account.address, positionInfo, token0Fees, token1Fees]) + }, [account.address, positionInfo, token0Fees, token1Fees, collectAsWETH]) - const { data } = useClaimLpFeesCalldataQuery(claimLpFeesParams) + const { data } = useClaimLpFeesCalldataQuery({ params: claimLpFeesParams, enabled: isOpen }) const signer = useEthersSigner() @@ -88,7 +93,7 @@ export function ClaimFeeModal({ @@ -133,6 +138,7 @@ export function ClaimFeeModal({ )} diff --git a/apps/web/src/pages/Pool/Positions/PositionPage.tsx b/apps/web/src/pages/Pool/Positions/PositionPage.tsx index 3d853ab40fd..64cb78998b4 100644 --- a/apps/web/src/pages/Pool/Positions/PositionPage.tsx +++ b/apps/web/src/pages/Pool/Positions/PositionPage.tsx @@ -6,10 +6,13 @@ import { LiquidityPositionAmountsTile } from 'components/Liquidity/LiquidityPosi import { LiquidityPositionInfo } from 'components/Liquidity/LiquidityPositionInfo' import { LiquidityPositionPriceRangeTile } from 'components/Liquidity/LiquidityPositionPriceRangeTile' import { PositionNFT } from 'components/Liquidity/PositionNFT' -import { parseRestPosition, useV3OrV4PositionDerivedInfo } from 'components/Liquidity/utils' +import { useV3OrV4PositionDerivedInfo } from 'components/Liquidity/hooks' +import { parseRestPosition } from 'components/Liquidity/utils' import { LoadingFullscreen, LoadingRows } from 'components/Loader/styled' import { useChainFromUrlParam } from 'constants/chains' +import { ZERO_ADDRESS } from 'constants/misc' import { usePositionTokenURI } from 'hooks/usePositionTokenURI' +import NotFound from 'pages/NotFound' import { ClaimFeeModal } from 'pages/Pool/Positions/ClaimFeeModal' import { LoadingRow, useRefetchOnLpModalClose } from 'pages/Pool/Positions/shared' import { useMemo, useState } from 'react' @@ -18,12 +21,12 @@ import { Navigate, useLocation, useNavigate, useParams } from 'react-router-dom' import { setOpenModal } from 'state/application/reducer' import { useAppDispatch } from 'state/hooks' import { ClickableTamaguiStyle } from 'theme/components' -import { Flex, Main, Switch, Text, styled } from 'ui/src' +import { Button, Flex, Main, Switch, Text, styled } from 'ui/src' import { useGetPositionQuery } from 'uniswap/src/data/rest/getPosition' import { FeatureFlags } from 'uniswap/src/features/gating/flags' import { useFeatureFlagWithLoading } from 'uniswap/src/features/gating/hooks' import { ModalName } from 'uniswap/src/features/telemetry/constants' -import { Trans } from 'uniswap/src/i18n' +import { Trans, useTranslation } from 'uniswap/src/i18n' import { NumberType, useFormatter } from 'utils/formatNumbers' import { useAccount } from 'wagmi' @@ -61,8 +64,20 @@ export const HeaderButton = styled(Flex, { } as const, }) +function parseTokenId(tokenId: string | undefined): BigNumber | undefined { + if (!tokenId) { + return undefined + } + try { + return BigNumber.from(tokenId) + } catch (error) { + return undefined + } +} + export default function PositionPage() { - const { tokenId } = useParams<{ tokenId: string }>() + const { tokenId: tokenIdFromUrl } = useParams<{ tokenId: string }>() + const tokenId = parseTokenId(tokenIdFromUrl) const chainInfo = useChainFromUrlParam() const account = useAccount() const { pathname } = useLocation() @@ -70,23 +85,19 @@ export default function PositionPage() { data, isLoading: positionLoading, refetch, - } = useGetPositionQuery( - account.address - ? { - owner: account.address, - protocolVersion: pathname.includes('v3') - ? ProtocolVersion.V3 - : pathname.includes('v4') - ? ProtocolVersion.V4 - : ProtocolVersion.UNSPECIFIED, - tokenId, - chainId: chainInfo?.id ?? account.chainId, - } - : undefined, - ) + } = useGetPositionQuery({ + owner: account?.address ?? ZERO_ADDRESS, + protocolVersion: pathname.includes('v3') + ? ProtocolVersion.V3 + : pathname.includes('v4') + ? ProtocolVersion.V4 + : ProtocolVersion.UNSPECIFIED, + tokenId: tokenIdFromUrl, + chainId: chainInfo?.id ?? account.chainId, + }) const position = data?.position const positionInfo = useMemo(() => parseRestPosition(position), [position]) - const metadata = usePositionTokenURI(tokenId ? BigNumber.from(tokenId) : undefined) + const metadata = usePositionTokenURI(tokenId, chainInfo?.id, positionInfo?.version) useRefetchOnLpModalClose(refetch) @@ -97,6 +108,7 @@ export default function PositionPage() { const { value: v4Enabled, isLoading } = useFeatureFlagWithLoading(FeatureFlags.V4Everywhere) const { formatCurrencyAmount } = useFormatter() const navigate = useNavigate() + const { t } = useTranslation() const { currency0Amount, currency1Amount, status } = positionInfo ?? {} const { @@ -115,7 +127,7 @@ export default function PositionPage() { return } - if (positionLoading || !position || !positionInfo || !currency0Amount || !currency1Amount) { + if (positionLoading) { return ( @@ -135,6 +147,24 @@ export default function PositionPage() { ) } + if (!position || !positionInfo || !currency0Amount || !currency1Amount) { + return ( + {t('position.notFound')}} + subtitle={ + + + {t('position.notFound.description')} + + + } + actionButton={} + /> + ) + } + + // TODO (WEB-4920): hide action buttons if position owner is not connected wallet. + return ( @@ -145,20 +175,27 @@ export default function PositionPage() { - + {status !== PositionStatus.CLOSED && ( - { - navigate(`/migrate/v3/${tokenId}`) - }} - > - - - - + {positionInfo.version === ProtocolVersion.V3 && ( + { + navigate(`/migrate/v3/${chainInfo?.urlParam}/${tokenIdFromUrl}`) + }} + > + + + + + )} { @@ -183,7 +220,7 @@ export default function PositionPage() { )} - + {'result' in metadata ? ( @@ -216,7 +253,7 @@ export default function PositionPage() { - + - + @@ -245,24 +282,25 @@ export default function PositionPage() { fiatValue1={fiatFeeValue1} /> )} - - - - - { - setCollectAsWeth((prev) => !prev) - }} - /> - + {positionInfo.version !== ProtocolVersion.V4 && ( + + + + + { + setCollectAsWeth((prev) => !prev) + }} + /> + + )} {priceOrdering && token0CurrentPrice && token1CurrentPrice && ( ) diff --git a/apps/web/src/pages/Pool/Positions/PositionsHeader.tsx b/apps/web/src/pages/Pool/Positions/PositionsHeader.tsx index 71ad02b19ae..042a4ece893 100644 --- a/apps/web/src/pages/Pool/Positions/PositionsHeader.tsx +++ b/apps/web/src/pages/Pool/Positions/PositionsHeader.tsx @@ -1,21 +1,22 @@ // eslint-disable-next-line no-restricted-imports import { PositionStatus, ProtocolVersion } from '@uniswap/client-pools/dist/pools/v1/types_pb' import { getProtocolStatusLabel, getProtocolVersionLabel } from 'components/Liquidity/utils' +import { useAccount } from 'hooks/useAccount' import { useMemo } from 'react' import { useNavigate } from 'react-router-dom' import { ClickableTamaguiStyle } from 'theme/components' import { Flex, LabeledCheckbox, Text } from 'ui/src' -import { ExternalLink } from 'ui/src/components/icons/ExternalLink' import { Plus } from 'ui/src/components/icons/Plus' import { RotatableChevron } from 'ui/src/components/icons/RotatableChevron' import { SortHorizontalLines } from 'ui/src/components/icons/SortHorizontalLines' import { ActionSheetDropdown } from 'uniswap/src/components/dropdowns/ActionSheetDropdown' import { NetworkFilter } from 'uniswap/src/components/network/NetworkFilter' import { useEnabledChains } from 'uniswap/src/features/settings/hooks' -import { useTranslation } from 'uniswap/src/i18n' +import { Trans, useTranslation } from 'uniswap/src/i18n' import { UniverseChainId } from 'uniswap/src/types/chains' type PositionsHeaderProps = { + showFilters?: boolean selectedChain: UniverseChainId | null selectedVersions?: ProtocolVersion[] selectedStatus?: PositionStatus[] @@ -25,6 +26,7 @@ type PositionsHeaderProps = { } export function PositionsHeader({ + showFilters = true, selectedChain, selectedVersions, selectedStatus, @@ -33,6 +35,7 @@ export function PositionsHeader({ onStatusChange, }: PositionsHeaderProps) { const { t } = useTranslation() + const { isConnected } = useAccount() const { chains } = useEnabledChains() const navigate = useNavigate() @@ -95,125 +98,118 @@ export function PositionsHeader({ ] }, [onStatusChange, onVersionChange, selectedStatus, selectedVersions, t]) - const createOptions = useMemo(() => { - return [ - { - key: 'PositionsHeader-create-v4', - onPress: () => { - navigate('/positions/create/v4') - }, - render: () => ( - - - {t('nav.tabs.createV4Position')} - - - - ), - }, - { - key: 'PositionsHeader-create-v3', - onPress: () => { - navigate('/positions/create/v3') - }, - render: () => ( - - - {t('nav.tabs.createV3Position')} - - - - ), - }, - { - key: 'PositionsHeader-create-v2', - onPress: () => { - navigate('/positions/create/v2') - }, - render: () => ( - - - {t('nav.tabs.createV2Position')} - - - - ), - }, - ] - }, [t, navigate]) + const createOptions = useMemo( + () => + [ProtocolVersion.V2, ProtocolVersion.V3, ProtocolVersion.V4].map((version) => { + const protocolVersionLabel = getProtocolVersionLabel(version)?.toLowerCase() + return { + key: `PositionsHeader-create-${protocolVersionLabel}`, + onPress: () => { + navigate(`/positions/create/${protocolVersionLabel}`) + }, + render: () => ( + + + + + + ), + } + }), + [navigate], + ) return ( {t('pool.positions.title')} - - - { - navigate('/positions/create/v4') - }} - > - - {t('common.new')} - - + + {isConnected && ( + + { + navigate('/positions/create/v4') + }} > - + + {t('common.new')} - - - - - + + + + + - - - + {showFilters && ( + <> + + + + + + + + + + )} - + )} ) } diff --git a/apps/web/src/pages/Pool/Positions/V2PositionPage.tsx b/apps/web/src/pages/Pool/Positions/V2PositionPage.tsx index 5869c3bc3ae..756e9d856ff 100644 --- a/apps/web/src/pages/Pool/Positions/V2PositionPage.tsx +++ b/apps/web/src/pages/Pool/Positions/V2PositionPage.tsx @@ -2,10 +2,13 @@ import { PositionStatus, ProtocolVersion } from '@uniswap/client-pools/dist/pools/v1/types_pb' import { BreadcrumbNavContainer, BreadcrumbNavLink } from 'components/BreadcrumbNav' import { LiquidityPositionInfo } from 'components/Liquidity/LiquidityPositionInfo' -import { parseRestPosition, useGetPoolTokenPercentage } from 'components/Liquidity/utils' -import { LoadingRows } from 'components/Loader/styled' +import { useGetPoolTokenPercentage } from 'components/Liquidity/hooks' +import { parseRestPosition } from 'components/Liquidity/utils' import { DoubleCurrencyAndChainLogo } from 'components/Logo/DoubleLogo' import { useChainFromUrlParam } from 'constants/chains' +import { ZERO_ADDRESS } from 'constants/misc' +import { LoadingRows } from 'pages/LegacyPool/styled' +import NotFound from 'pages/NotFound' import { HeaderButton } from 'pages/Pool/Positions/PositionPage' import { LoadingRow, useRefetchOnLpModalClose } from 'pages/Pool/Positions/shared' import { useMemo } from 'react' @@ -13,14 +16,14 @@ import { ChevronRight } from 'react-feather' import { Navigate, useNavigate, useParams } from 'react-router-dom' import { setOpenModal } from 'state/application/reducer' import { useAppDispatch } from 'state/hooks' -import { Flex, Main, Text, styled } from 'ui/src' +import { Button, Flex, Main, Text, styled } from 'ui/src' import { useGetPositionQuery } from 'uniswap/src/data/rest/getPosition' import { FeatureFlags } from 'uniswap/src/features/gating/flags' import { useFeatureFlagWithLoading } from 'uniswap/src/features/gating/hooks' import { useLocalizationContext } from 'uniswap/src/features/language/LocalizationContext' import { ModalName } from 'uniswap/src/features/telemetry/constants' import { useUSDCValue } from 'uniswap/src/features/transactions/swap/hooks/useUSDCPrice' -import { Trans } from 'uniswap/src/i18n' +import { Trans, useTranslation } from 'uniswap/src/i18n' import { NumberType } from 'utilities/src/format/types' import { useAccount } from 'wagmi' @@ -44,21 +47,18 @@ export default function V2PositionPage() { data, isLoading: positionLoading, refetch, - } = useGetPositionQuery( - account.address - ? { - owner: account.address, - protocolVersion: ProtocolVersion.V2, - pairAddress, - chainId: chainInfo?.id ?? account.chainId, - } - : undefined, - ) + } = useGetPositionQuery({ + owner: account?.address ?? ZERO_ADDRESS, + protocolVersion: ProtocolVersion.V2, + pairAddress, + chainId: chainInfo?.id ?? account.chainId, + }) const position = data?.position const positionInfo = useMemo(() => parseRestPosition(position), [position]) const dispatch = useAppDispatch() const navigate = useNavigate() const { formatCurrencyAmount, formatPercent } = useLocalizationContext() + const { t } = useTranslation() useRefetchOnLpModalClose(refetch) @@ -74,7 +74,7 @@ export default function V2PositionPage() { return } - if (positionLoading || !positionInfo || !liquidityAmount || !currency0Amount || !currency1Amount) { + if (positionLoading) { return ( @@ -95,9 +95,25 @@ export default function V2PositionPage() { ) } + if (!positionInfo || !liquidityAmount || !currency0Amount || !currency1Amount) { + return ( + {t('position.notFound')}} + subtitle={ + + + {t('position.notFound.description')} + + + } + actionButton={} + /> + ) + } + return ( - + diff --git a/apps/web/src/pages/Pool/Positions/create/AddHook.tsx b/apps/web/src/pages/Pool/Positions/create/AddHook.tsx index 6119ca59ca9..5b588748c5f 100644 --- a/apps/web/src/pages/Pool/Positions/create/AddHook.tsx +++ b/apps/web/src/pages/Pool/Positions/create/AddHook.tsx @@ -1,58 +1,133 @@ +import { HookModal } from 'components/Liquidity/HookModal' +import { useCreatePositionContext } from 'pages/Pool/Positions/create/CreatePositionContext' import { AdvancedButton } from 'pages/Pool/Positions/create/shared' -import { useState } from 'react' -import { Button } from 'ui/src' +import { useCallback, useRef, useState } from 'react' +import { Button, Text, TouchableArea, styled } from 'ui/src' import { DocumentList } from 'ui/src/components/icons/DocumentList' import { X } from 'ui/src/components/icons/X' import { Flex } from 'ui/src/components/layout/Flex' import { fonts } from 'ui/src/theme' import { TextInput } from 'uniswap/src/components/input/TextInput' import { useTranslation } from 'uniswap/src/i18n' +import { getValidAddress } from 'uniswap/src/utils/addresses' +import { useOnClickOutside } from 'utilities/src/react/hooks' + +const MenuFlyout = styled(Flex, { + animation: 'fastHeavy', + enterStyle: { top: 30, opacity: 0 }, + exitStyle: { top: 30, opacity: 0 }, + width: 'calc(100% - 8px)', + backgroundColor: '$surface2', + borderColor: '$surface3', + borderWidth: 1, + borderRadius: '$rounded12', + position: 'absolute', + top: 40, + left: '$spacing4', + zIndex: 100, + p: '$padding16', + opacity: 1, + shadowColor: '$shadowColor', + shadowOffset: { width: 0, height: 25 }, + shadowOpacity: 0.2, + shadowRadius: 50, +}) + +function AutocompleteFlyout({ address, handleSelectAddress }: { address: string; handleSelectAddress: () => void }) { + const { t } = useTranslation() + const validAddress = getValidAddress(address) + + return ( + + {validAddress ? ( + + {address} + + ) : ( + {t('position.addingHook.invalidAddress')} + )} + + ) +} export function AddHook() { const { t } = useTranslation() + const [isFocusing, setFocus] = useState(false) + const handleFocus = useCallback((focus: boolean) => setFocus(focus), []) + + const inputWrapperNode = useRef(null) + useOnClickOutside(inputWrapperNode, isFocusing ? () => handleFocus(false) : undefined) + const [hookInputEnabled, setHookInputEnabled] = useState(false) - const [hookAddress, setHookAddress] = useState('') + const [hookModalOpen, setHookModalOpen] = useState(false) + + const [hook, setHook] = useState<{ address: string; confirmed: boolean }>({ + address: '', + confirmed: false, + }) + const { setPositionState } = useCreatePositionContext() const handleToggleHookInput = () => { setHookInputEnabled((prev) => !prev) - setHookAddress('') + setHook({ address: '', confirmed: false }) + } + + const onSelectAddress = () => { + setPositionState((state) => ({ + ...state, + hook: hook.address, + })) + + setHookModalOpen(true) + setHook((state) => ({ + ...state, + confirmed: true, + })) } if (hookInputEnabled) { + // TODO: investigate bug with invalid input and then making it valid after + const showFlyout = isFocusing && !!hook.address && hook.address.length === 42 + return ( - - setHookAddress(text)} - /> - - + <> + setHookModalOpen(false)} /> + + setHook({ address: text, confirmed: false })} + onFocus={() => handleFocus(true)} + /> + + {showFlyout && } + + ) } diff --git a/apps/web/src/pages/Pool/Positions/create/ContextProviders.tsx b/apps/web/src/pages/Pool/Positions/create/ContextProviders.tsx index b587362fc60..84c13fcb030 100644 --- a/apps/web/src/pages/Pool/Positions/create/ContextProviders.tsx +++ b/apps/web/src/pages/Pool/Positions/create/ContextProviders.tsx @@ -1,19 +1,12 @@ -import { BigNumber } from '@ethersproject/bignumber' -import { useQuery } from '@tanstack/react-query' -// eslint-disable-next-line no-restricted-imports -import { ProtocolVersion } from '@uniswap/client-pools/dist/pools/v1/types_pb' -import { CurrencyAmount } from '@uniswap/sdk-core' import { FeeTierSearchModal } from 'components/Liquidity/FeeTierSearchModal' import { DepositState } from 'components/Liquidity/types' -import { getProtocolItems } from 'components/Liquidity/utils' -import { ZERO_ADDRESS } from 'constants/misc' import { useAccount } from 'hooks/useAccount' -import { useCurrencyBalances } from 'lib/hooks/useCurrencyBalance' import { CreatePositionContext, CreateTxContext, DEFAULT_DEPOSIT_STATE, - DEFAULT_PRICE_RANGE_STATE, + DEFAULT_PRICE_RANGE_STATE_CREATING_POOL, + DEFAULT_PRICE_RANGE_STATE_POOL_EXISTS, DepositContext, PriceRangeContext, useCreatePositionContext, @@ -31,16 +24,19 @@ import { PositionState, PriceRangeState, } from 'pages/Pool/Positions/create/types' -import { useMemo, useState } from 'react' -import { useProvider } from 'uniswap/src/contexts/UniswapContext' +import { + generateAddLiquidityApprovalParams, + generateCreateCalldataQueryParams, + generateCreatePositionTxRequest, +} from 'pages/Pool/Positions/create/utils' +import { useEffect, useMemo, useState } from 'react' +import { PositionField } from 'types/position' +import { nativeOnChain } from 'uniswap/src/constants/tokens' +import { useAccountMeta } from 'uniswap/src/contexts/UniswapContext' import { useCheckLpApprovalQuery } from 'uniswap/src/data/apiClients/tradingApi/useCheckLpApprovalQuery' import { useCreateLpPositionCalldataQuery } from 'uniswap/src/data/apiClients/tradingApi/useCreateLpPositionCalldataQuery' -import { CheckApprovalLPRequest, CreateLPPositionRequest } from 'uniswap/src/data/tradingApi/__generated__' -import { CreatePositionTxAndGasInfo } from 'uniswap/src/features/transactions/liquidity/types' -import { getWrapTransactionRequest } from 'uniswap/src/features/transactions/swap/hooks/useWrapTransactionRequest' -import { validatePermit, validateTransactionRequest } from 'uniswap/src/features/transactions/swap/utils/trade' -import { WrapType } from 'uniswap/src/features/transactions/types/wrap' import { UniverseChainId } from 'uniswap/src/types/chains' +import { usePrevious } from 'utilities/src/react/hooks' import { ONE_SECOND_MS } from 'utilities/src/time/time' export function CreatePositionContextProvider({ @@ -54,6 +50,21 @@ export function CreatePositionContextProvider({ const [step, setStep] = useState(PositionFlowStep.SELECT_TOKENS_AND_FEE_TIER) const derivedPositionInfo = useDerivedPositionInfo(positionState) const [feeTierSearchModalOpen, setFeeTierSearchModalOpen] = useState(false) + const [createPoolInfoDismissed, setCreatePoolInfoDismissed] = useState(false) + + const account = useAccount() + const prevChainId = usePrevious(account.chainId) + useEffect(() => { + if (prevChainId && prevChainId !== account.chainId) { + setPositionState((prevState) => ({ + ...prevState, + currencyInputs: { + [PositionField.TOKEN0]: nativeOnChain(account.chainId ?? UniverseChainId.Mainnet), + }, + })) + setStep(PositionFlowStep.SELECT_TOKENS_AND_FEE_TIER) + } + }, [account.chainId, prevChainId]) return ( {children} @@ -74,7 +87,19 @@ export function CreatePositionContextProvider({ } export function PriceRangeContextProvider({ children }: { children: React.ReactNode }) { - const [priceRangeState, setPriceRangeState] = useState(DEFAULT_PRICE_RANGE_STATE) + const { derivedPositionInfo } = useCreatePositionContext() + const [priceRangeState, setPriceRangeState] = useState(DEFAULT_PRICE_RANGE_STATE_CREATING_POOL) + + useEffect(() => { + // creatingPoolOrPair is calculated in the previous step of the create flow, so + // it's safe to reset PriceRangeState to defaults when it changes. + setPriceRangeState( + derivedPositionInfo.creatingPoolOrPair + ? DEFAULT_PRICE_RANGE_STATE_CREATING_POOL + : DEFAULT_PRICE_RANGE_STATE_POOL_EXISTS, + ) + }, [derivedPositionInfo.creatingPoolOrPair]) + const derivedPriceRangeInfo = useDerivedPriceRangeInfo(priceRangeState) return ( @@ -96,246 +121,57 @@ export function DepositContextProvider({ children }: { children: React.ReactNode } export function CreateTxContextProvider({ children }: { children: React.ReactNode }) { - const account = useAccount() - const provider = useProvider(account.chainId ?? UniverseChainId.Mainnet) - const { - priceRangeState: { fullRange }, - derivedPriceRangeInfo: { tickSpaceLimits, ticks }, - } = usePriceRangeContext() - - const { - derivedDepositInfo: { currencyAmounts }, - } = useDepositContext() - + const account = useAccountMeta() const { derivedPositionInfo, positionState } = useCreatePositionContext() - - const selectedNativeCurrencyAmount = useMemo(() => { - const selectedNativeCurrency = positionState.currencyInputs.TOKEN0?.isNative - ? positionState.currencyInputs.TOKEN0 - : positionState.currencyInputs.TOKEN1?.isNative - ? positionState.currencyInputs.TOKEN1 - : undefined - if (!selectedNativeCurrency) { - return undefined - } - if (currencyAmounts?.TOKEN0?.currency.equals(selectedNativeCurrency.wrapped)) { - return CurrencyAmount.fromFractionalAmount( - selectedNativeCurrency, - currencyAmounts.TOKEN0.numerator, - currencyAmounts.TOKEN0.denominator, - ) - } else if (currencyAmounts?.TOKEN1?.currency.equals(selectedNativeCurrency.wrapped)) { - return CurrencyAmount.fromFractionalAmount( - selectedNativeCurrency, - currencyAmounts.TOKEN1.numerator, - currencyAmounts.TOKEN1.denominator, - ) - } - return undefined - }, [currencyAmounts, positionState.currencyInputs]) - - const [wrappedNativeBalance] = useCurrencyBalances( - account.address, - [selectedNativeCurrencyAmount?.currency?.wrapped].filter(Boolean), - ) - - const { data: wrapTxRequest } = useQuery({ - queryKey: ['CreateTxContextProvider-wrapTxRequest', derivedPositionInfo, account.address], - queryFn: async () => { - const txRequest = await getWrapTransactionRequest( - provider, - false, - account.chainId ?? UniverseChainId.Mainnet, - account.address, - WrapType.Wrap, - selectedNativeCurrencyAmount, - ) - return { - ...txRequest, - value: BigNumber.from(txRequest?.value)?.toString(), - } - }, - enabled: Boolean( - positionState.protocolVersion !== ProtocolVersion.V4 && - account.address && - selectedNativeCurrencyAmount && - wrappedNativeBalance?.greaterThan(selectedNativeCurrencyAmount), - ), - }) - - const addLiquidityApprovalParams: CheckApprovalLPRequest | undefined = useMemo(() => { - const apiProtocolItems = getProtocolItems(positionState.protocolVersion) - if (!account.address || !apiProtocolItems || !currencyAmounts?.TOKEN0 || !currencyAmounts?.TOKEN1) { - return undefined - } - return { - walletAddress: account.address, - chainId: derivedPositionInfo.currencies.TOKEN0?.chainId, - protocol: apiProtocolItems, - token0: - derivedPositionInfo.currencies.TOKEN0?.isNative && positionState.protocolVersion === ProtocolVersion.V4 - ? ZERO_ADDRESS - : derivedPositionInfo.currencies.TOKEN0?.wrapped.address, - token1: - derivedPositionInfo.currencies.TOKEN1?.isNative && positionState.protocolVersion === ProtocolVersion.V4 - ? ZERO_ADDRESS - : derivedPositionInfo.currencies.TOKEN1?.wrapped.address, - amount0: currencyAmounts?.TOKEN0?.quotient.toString(), - amount1: currencyAmounts?.TOKEN1?.quotient.toString(), - } - }, [account.address, positionState.protocolVersion, derivedPositionInfo.currencies, currencyAmounts]) + const { derivedDepositInfo } = useDepositContext() + const { priceRangeState, derivedPriceRangeInfo } = usePriceRangeContext() + + const addLiquidityApprovalParams = useMemo(() => { + return generateAddLiquidityApprovalParams({ + account, + positionState, + derivedPositionInfo, + derivedDepositInfo, + }) + }, [account, derivedDepositInfo, derivedPositionInfo, positionState]) const { data: approvalCalldata } = useCheckLpApprovalQuery({ params: addLiquidityApprovalParams, staleTime: 5 * ONE_SECOND_MS, }) - const createCalldataQueryParams: CreateLPPositionRequest | undefined = useMemo(() => { - const apiProtocolItems = getProtocolItems(positionState.protocolVersion) - const tickLower = fullRange ? tickSpaceLimits[0] : ticks?.[0] - const tickUpper = fullRange ? tickSpaceLimits[1] : ticks?.[1] - if ( - !account.address || - !apiProtocolItems || - !derivedPositionInfo.currencies.TOKEN0 || - !derivedPositionInfo.currencies.TOKEN1 || - !currencyAmounts?.TOKEN0 || - !currencyAmounts?.TOKEN1 || - !tickLower || - !tickUpper - ) { - return undefined - } - let poolLiquidity: string | undefined - let currentTick: number | undefined - let sqrtRatioX96: string | undefined - let tickSpacing: number | undefined - if ( - derivedPositionInfo.protocolVersion === ProtocolVersion.V3 || - derivedPositionInfo.protocolVersion === ProtocolVersion.V4 - ) { - if (!derivedPositionInfo.pool) { - return undefined - } else { - poolLiquidity = derivedPositionInfo.pool.liquidity.toString() - currentTick = derivedPositionInfo.pool.tickCurrent - sqrtRatioX96 = derivedPositionInfo.pool.sqrtRatioX96.toString() - tickSpacing = derivedPositionInfo.pool.tickSpacing - } - } - if (derivedPositionInfo.protocolVersion === ProtocolVersion.V2 && !derivedPositionInfo.pair) { - return undefined - } - const { token0Approval, token1Approval, positionTokenApproval, permitData } = approvalCalldata ?? {} - const needsWrap = Boolean( - derivedPositionInfo.protocolVersion !== ProtocolVersion.V4 && selectedNativeCurrencyAmount, - ) - return { - simulateTransaction: !(permitData || token0Approval || token1Approval || positionTokenApproval || needsWrap), - protocol: apiProtocolItems, - walletAddress: account.address, - chainId: derivedPositionInfo.currencies.TOKEN0?.chainId, - amount0: currencyAmounts?.TOKEN0?.quotient.toString(), - amount1: currencyAmounts?.TOKEN1?.quotient.toString(), - poolLiquidity, - currentTick, - sqrtRatioX96, - // todo: set the initial price if the pool doesn't already exist - // initialPrice: derivedPositionInfo.pool ? undefined : 100 - position: { - tickLower, - tickUpper, - pool: { - tickSpacing, - token0: - derivedPositionInfo.currencies.TOKEN0?.isNative && positionState.protocolVersion === ProtocolVersion.V4 - ? ZERO_ADDRESS - : derivedPositionInfo.currencies.TOKEN0?.wrapped.address, - token1: - derivedPositionInfo.currencies.TOKEN1?.isNative && positionState.protocolVersion === ProtocolVersion.V4 - ? ZERO_ADDRESS - : derivedPositionInfo.currencies.TOKEN1?.wrapped.address, - fee: positionState.fee, - hooks: positionState.hook, - }, - }, - } + const createCalldataQueryParams = useMemo(() => { + return generateCreateCalldataQueryParams({ + account, + approvalCalldata, + positionState, + derivedPositionInfo, + priceRangeState, + derivedPriceRangeInfo, + derivedDepositInfo, + }) }, [ account, - positionState, - derivedPositionInfo, - currencyAmounts, - fullRange, - ticks, - tickSpaceLimits, approvalCalldata, - selectedNativeCurrencyAmount, + derivedDepositInfo, + derivedPositionInfo, + derivedPriceRangeInfo, + positionState, + priceRangeState, ]) - const { data: createCalldata } = useCreateLpPositionCalldataQuery({ params: createCalldataQueryParams, staleTime: 5 * ONE_SECOND_MS, }) const validatedValue = useMemo(() => { - if (!createCalldata || !currencyAmounts?.TOKEN0 || !currencyAmounts?.TOKEN1) { - return undefined - } - const validatedApprove0Request = validateTransactionRequest(approvalCalldata?.token0Approval) - if (approvalCalldata?.token0Approval && !validatedApprove0Request) { - return undefined - } - - const validatedApprove1Request = validateTransactionRequest(approvalCalldata?.token1Approval) - if (approvalCalldata?.token1Approval && !validatedApprove1Request) { - return undefined - } - - const validatedPermitRequest = validatePermit(approvalCalldata?.permitData) - if (approvalCalldata?.permitData && !validatedPermitRequest) { - return undefined - } - - const validatedWrapRequest = validateTransactionRequest(wrapTxRequest) - if (wrapTxRequest && !validatedWrapRequest) { - return undefined - } - - const txRequest = validateTransactionRequest(createCalldata.create) - if (!txRequest) { - return undefined - } - - return { - type: 'create', - unsigned: Boolean(approvalCalldata?.permitData), - protocolVersion: derivedPositionInfo.protocolVersion, - createPositionRequestArgs: createCalldataQueryParams, - action: { - nativeCurrencyAmount: selectedNativeCurrencyAmount, - currency0Amount: currencyAmounts.TOKEN0, - currency1Amount: currencyAmounts.TOKEN1, - liquidityToken: - derivedPositionInfo.protocolVersion === ProtocolVersion.V2 - ? derivedPositionInfo.pair?.liquidityToken - : undefined, - }, - wrapTxRequest: validatedWrapRequest, - approveToken0Request: validatedApprove0Request, - approveToken1Request: validatedApprove1Request, - txRequest, - approvePositionTokenRequest: undefined, - revocationTxRequest: undefined, - permit: validatedPermitRequest, - } satisfies CreatePositionTxAndGasInfo - }, [ - approvalCalldata, - createCalldata, - createCalldataQueryParams, - derivedPositionInfo, - currencyAmounts, - wrapTxRequest, - selectedNativeCurrencyAmount, - ]) + return generateCreatePositionTxRequest({ + approvalCalldata, + createCalldata, + createCalldataQueryParams, + derivedPositionInfo, + derivedDepositInfo, + }) + }, [approvalCalldata, createCalldata, createCalldataQueryParams, derivedPositionInfo, derivedDepositInfo]) return {children} } diff --git a/apps/web/src/pages/Pool/Positions/create/CreatePosition.tsx b/apps/web/src/pages/Pool/Positions/create/CreatePosition.tsx index 6d21d8ccbcc..33cdf597786 100644 --- a/apps/web/src/pages/Pool/Positions/create/CreatePosition.tsx +++ b/apps/web/src/pages/Pool/Positions/create/CreatePosition.tsx @@ -3,6 +3,7 @@ import { ProtocolVersion } from '@uniswap/client-pools/dist/pools/v1/types_pb' import { BreadcrumbNavContainer, BreadcrumbNavLink } from 'components/BreadcrumbNav' import { getProtocolVersionLabel, parseProtocolVersion } from 'components/Liquidity/utils' import { PoolProgressIndicator } from 'components/PoolProgressIndicator/PoolProgressIndicator' +import { useAccount } from 'hooks/useAccount' import { CreatePositionContextProvider, CreateTxContextProvider, @@ -11,32 +12,36 @@ import { } from 'pages/Pool/Positions/create/ContextProviders' import { DEFAULT_DEPOSIT_STATE, - DEFAULT_PRICE_RANGE_STATE, + DEFAULT_PRICE_RANGE_STATE_POOL_EXISTS, useCreatePositionContext, useDepositContext, usePriceRangeContext, } from 'pages/Pool/Positions/create/CreatePositionContext' import { DepositStep } from 'pages/Pool/Positions/create/Deposit' import { EditRangeSelectionStep, EditSelectTokensStep } from 'pages/Pool/Positions/create/EditStep' -import { SelectPriceRangeStep } from 'pages/Pool/Positions/create/RangeSelectionStep' +import { SelectPriceRangeStep, SelectPriceRangeStepV2 } from 'pages/Pool/Positions/create/RangeSelectionStep' import { SelectTokensStep } from 'pages/Pool/Positions/create/SelectTokenStep' import { DEFAULT_POSITION_STATE, PositionFlowStep } from 'pages/Pool/Positions/create/types' import { useCallback, useMemo } from 'react' import { ChevronRight } from 'react-feather' import { Navigate, useParams } from 'react-router-dom' -import { Button, Flex, Text } from 'ui/src' +import { PositionField } from 'types/position' +import { Button, Flex, Text, useMedia } from 'ui/src' import { RotatableChevron } from 'ui/src/components/icons/RotatableChevron' import { RotateLeft } from 'ui/src/components/icons/RotateLeft' import { Settings } from 'ui/src/components/icons/Settings' import { iconSizes } from 'ui/src/theme/iconSizes' import { ActionSheetDropdown } from 'uniswap/src/components/dropdowns/ActionSheetDropdown' +import { nativeOnChain } from 'uniswap/src/constants/tokens' import { FeatureFlags } from 'uniswap/src/features/gating/flags' import { useFeatureFlagWithLoading } from 'uniswap/src/features/gating/hooks' import { Trans, useTranslation } from 'uniswap/src/i18n' +import { UniverseChainId } from 'uniswap/src/types/chains' function CreatePositionInner() { const { positionState: { protocolVersion }, + derivedPositionInfo: { creatingPoolOrPair }, step, setStep, } = useCreatePositionContext() @@ -44,20 +49,28 @@ function CreatePositionInner() { const handleContinue = useCallback(() => { if (v2Selected) { - setStep(PositionFlowStep.DEPOSIT) + if (step === PositionFlowStep.SELECT_TOKENS_AND_FEE_TIER && creatingPoolOrPair) { + setStep(PositionFlowStep.PRICE_RANGE) + } else { + setStep(PositionFlowStep.DEPOSIT) + } } else { setStep((prevStep) => prevStep + 1) } - }, [setStep, v2Selected]) + }, [creatingPoolOrPair, setStep, step, v2Selected]) return ( - + {step === PositionFlowStep.SELECT_TOKENS_AND_FEE_TIER ? ( ) : step === PositionFlowStep.PRICE_RANGE ? ( <> - + {v2Selected ? ( + + ) : ( + + )} ) : ( <> @@ -74,11 +87,20 @@ const Sidebar = () => { const { t } = useTranslation() const { positionState: { protocolVersion }, + derivedPositionInfo: { creatingPoolOrPair }, step, } = useCreatePositionContext() const PoolProgressSteps = useMemo(() => { if (protocolVersion === ProtocolVersion.V2) { + if (creatingPoolOrPair) { + return [ + { label: t(`position.step.select`), active: step === PositionFlowStep.SELECT_TOKENS_AND_FEE_TIER }, + { label: t(`position.step.price`), active: step === PositionFlowStep.PRICE_RANGE }, + { label: t(`position.step.deposit`), active: step == PositionFlowStep.DEPOSIT }, + ] + } + return [ { label: t(`position.step.select`), active: step === PositionFlowStep.SELECT_TOKENS_AND_FEE_TIER }, { label: t(`position.step.deposit`), active: step == PositionFlowStep.DEPOSIT }, @@ -90,40 +112,38 @@ const Sidebar = () => { { label: t(`position.step.range`), active: step === PositionFlowStep.PRICE_RANGE }, { label: t(`position.step.deposit`), active: step == PositionFlowStep.DEPOSIT }, ] - }, [protocolVersion, step, t]) + }, [creatingPoolOrPair, protocolVersion, step, t]) return ( - - - - - - - - - - - + ) } const Toolbar = () => { - const { - positionState: { protocolVersion }, - setPositionState, - setStep, - } = useCreatePositionContext() - const { setPriceRangeState } = usePriceRangeContext() - const { setDepositState } = useDepositContext() + const { positionState, setPositionState, setStep } = useCreatePositionContext() + const { protocolVersion } = positionState + const { priceRangeState, setPriceRangeState } = usePriceRangeContext() + const { depositState, setDepositState } = useDepositContext() + + const isFormUnchanged = useMemo(() => { + // Check if all form fields (except protocol version) are set to their default values + return ( + positionState.currencyInputs === DEFAULT_POSITION_STATE.currencyInputs && + positionState.fee === DEFAULT_POSITION_STATE.fee && + positionState.hook === DEFAULT_POSITION_STATE.hook && + priceRangeState.initialPrice === DEFAULT_PRICE_RANGE_STATE_POOL_EXISTS.initialPrice && + depositState === DEFAULT_DEPOSIT_STATE + ) + }, [positionState.currencyInputs, positionState.fee, positionState.hook, priceRangeState, depositState]) const handleReset = useCallback(() => { - setPositionState(DEFAULT_POSITION_STATE) - setPriceRangeState(DEFAULT_PRICE_RANGE_STATE) + setPositionState({ ...DEFAULT_POSITION_STATE, protocolVersion }) + setPriceRangeState(DEFAULT_PRICE_RANGE_STATE_POOL_EXISTS) setDepositState(DEFAULT_DEPOSIT_STATE) setStep(PositionFlowStep.SELECT_TOKENS_AND_FEE_TIER) - }, [setDepositState, setPositionState, setPriceRangeState, setStep]) + }, [protocolVersion, setDepositState, setPositionState, setPriceRangeState, setStep]) const handleVersionChange = useCallback( (version: ProtocolVersion) => { @@ -132,7 +152,7 @@ const Toolbar = () => { currencyInputs: prevState.currencyInputs, protocolVersion: version, })) - setPriceRangeState(DEFAULT_PRICE_RANGE_STATE) + setPriceRangeState(DEFAULT_PRICE_RANGE_STATE_POOL_EXISTS) setStep(PositionFlowStep.SELECT_TOKENS_AND_FEE_TIER) }, [setPositionState, setPriceRangeState, setStep], @@ -160,7 +180,7 @@ const Toolbar = () => { ) return ( - + diff --git a/apps/web/src/pages/Pool/Positions/create/Deposit.tsx b/apps/web/src/pages/Pool/Positions/create/Deposit.tsx index e43751497c4..f70cc57a398 100644 --- a/apps/web/src/pages/Pool/Positions/create/Deposit.tsx +++ b/apps/web/src/pages/Pool/Positions/create/Deposit.tsx @@ -12,12 +12,8 @@ import { Button, Flex, FlexProps, Text } from 'ui/src' import { Trans } from 'uniswap/src/i18n' export const DepositStep = ({ ...rest }: FlexProps) => { - const { - derivedPositionInfo: { sortedTokens }, - } = useCreatePositionContext() - const { - derivedPriceRangeInfo: { deposit0Disabled, deposit1Disabled }, - } = usePriceRangeContext() + const { derivedPositionInfo } = useCreatePositionContext() + const { derivedPriceRangeInfo } = usePriceRangeContext() const { setDepositState, derivedDepositInfo: { formattedAmounts, currencyAmounts, currencyAmountsUSDValue, currencyBalances, error }, @@ -44,12 +40,14 @@ export const DepositStep = ({ ...rest }: FlexProps) => { setIsReviewModalOpen(true) }, []) - const [token0, token1] = sortedTokens ?? [undefined, undefined] + const [token0, token1] = derivedPositionInfo.currencies if (!token0 || !token1) { return null } + const { deposit0Disabled, deposit1Disabled } = derivedPriceRangeInfo + return ( <> @@ -78,7 +76,9 @@ export const DepositStep = ({ ...rest }: FlexProps) => { deposit1Disabled={deposit1Disabled} /> setIsReviewModalOpen(false)} /> diff --git a/apps/web/src/pages/Pool/Positions/create/EditStep.tsx b/apps/web/src/pages/Pool/Positions/create/EditStep.tsx index 0167c961d6f..a9971aa6e45 100644 --- a/apps/web/src/pages/Pool/Positions/create/EditStep.tsx +++ b/apps/web/src/pages/Pool/Positions/create/EditStep.tsx @@ -1,14 +1,23 @@ +// eslint-disable-next-line no-restricted-imports +import { LiquidityPositionInfoBadges } from 'components/Liquidity/LiquidityPositionInfoBadges' +import { getProtocolVersionLabel } from 'components/Liquidity/utils' import { DoubleCurrencyLogo } from 'components/Logo/DoubleLogo' -import { useCreatePositionContext, usePriceRangeContext } from 'pages/Pool/Positions/create/CreatePositionContext' -import { Container } from 'pages/Pool/Positions/create/shared' +import { + DEFAULT_DEPOSIT_STATE, + DEFAULT_PRICE_RANGE_STATE_POOL_EXISTS, + useCreatePositionContext, + useDepositContext, + usePriceRangeContext, +} from 'pages/Pool/Positions/create/CreatePositionContext' +import { Container, formatPrices } from 'pages/Pool/Positions/create/shared' import { PositionFlowStep } from 'pages/Pool/Positions/create/types' +import { getInvertedTuple } from 'pages/Pool/Positions/create/utils' import { useCallback, useMemo } from 'react' import { Button, Flex, FlexProps, Text } from 'ui/src' import { Edit } from 'ui/src/components/icons/Edit' import { iconSizes } from 'ui/src/theme' import { useLocalizationContext } from 'uniswap/src/features/language/LocalizationContext' import { Trans } from 'uniswap/src/i18n' -import { NumberType } from 'utilities/src/format/types' const EditStep = ({ children, onClick, ...rest }: { children: JSX.Element; onClick: () => void } & FlexProps) => { return ( @@ -25,15 +34,19 @@ const EditStep = ({ children, onClick, ...rest }: { children: JSX.Element; onCli } export const EditSelectTokensStep = (props?: FlexProps) => { - const { - setStep, - derivedPositionInfo: { currencies }, - } = useCreatePositionContext() - const { TOKEN0: token0, TOKEN1: token1 } = currencies + const { setStep, derivedPositionInfo, positionState } = useCreatePositionContext() + const { setPriceRangeState } = usePriceRangeContext() + const { setDepositState } = useDepositContext() + const { currencies, protocolVersion } = derivedPositionInfo + const { fee, hook } = positionState + const [token0, token1] = currencies + const versionLabel = getProtocolVersionLabel(protocolVersion) const handleEdit = useCallback(() => { + setPriceRangeState(DEFAULT_PRICE_RANGE_STATE_POOL_EXISTS) + setDepositState(DEFAULT_DEPOSIT_STATE) setStep(PositionFlowStep.SELECT_TOKENS_AND_FEE_TIER) - }, [setStep]) + }, [setDepositState, setPriceRangeState, setStep]) return ( @@ -44,34 +57,36 @@ export const EditSelectTokensStep = (props?: FlexProps) => { / {token1?.symbol} + + + ) } export const EditRangeSelectionStep = (props?: FlexProps) => { - const { setStep } = useCreatePositionContext() const { - derivedPriceRangeInfo: { baseAndQuoteTokens, prices, ticksAtLimit, isSorted }, + setStep, + derivedPositionInfo: { currencies }, + } = useCreatePositionContext() + const { + priceRangeState: { priceInverted }, + derivedPriceRangeInfo, } = usePriceRangeContext() + const { setDepositState } = useDepositContext() const { formatNumberOrString } = useLocalizationContext() - const [baseCurrency, quoteCurrency] = baseAndQuoteTokens ?? [undefined, undefined] + const [baseCurrency, quoteCurrency] = getInvertedTuple(currencies, priceInverted) const handleEdit = useCallback(() => { + setDepositState(DEFAULT_DEPOSIT_STATE) setStep(PositionFlowStep.PRICE_RANGE) - }, [setStep]) + }, [setDepositState, setStep]) const formattedPrices = useMemo(() => { - const lowerPriceFormatted = ticksAtLimit[isSorted ? 0 : 1] - ? '0' - : formatNumberOrString({ value: prices?.[0]?.toSignificant(), type: NumberType.TokenTx }) - const upperPriceFormatted = ticksAtLimit[isSorted ? 1 : 0] - ? '∞' - : formatNumberOrString({ value: prices?.[1]?.toSignificant(), type: NumberType.TokenTx }) - - return [lowerPriceFormatted, upperPriceFormatted] - }, [formatNumberOrString, isSorted, prices, ticksAtLimit]) + return formatPrices(derivedPriceRangeInfo, formatNumberOrString) + }, [formatNumberOrString, derivedPriceRangeInfo]) return ( diff --git a/apps/web/src/pages/Pool/Positions/create/RangeSelectionStep.tsx b/apps/web/src/pages/Pool/Positions/create/RangeSelectionStep.tsx index b257f63aed3..19c79224a2b 100644 --- a/apps/web/src/pages/Pool/Positions/create/RangeSelectionStep.tsx +++ b/apps/web/src/pages/Pool/Positions/create/RangeSelectionStep.tsx @@ -1,16 +1,20 @@ // eslint-disable-next-line no-restricted-imports import { ProtocolVersion } from '@uniswap/client-pools/dist/pools/v1/types_pb' +import { Currency, Price } from '@uniswap/sdk-core' +import { calculateInvertedPrice } from 'components/Liquidity/utils' import LiquidityChartRangeInput from 'components/LiquidityChartRangeInput' import { useCreatePositionContext, usePriceRangeContext } from 'pages/Pool/Positions/create/CreatePositionContext' -import { Container } from 'pages/Pool/Positions/create/shared' +import { Container, CreatingPoolInfo } from 'pages/Pool/Positions/create/shared' +import { getInvertedTuple } from 'pages/Pool/Positions/create/utils' import { useCallback, useMemo, useState } from 'react' import { Minus, Plus } from 'react-feather' import { useRangeHopCallbacks } from 'state/mint/v3/hooks' -import { Button, Flex, FlexProps, SegmentedControl, Text, useSporeColors } from 'ui/src' -import { SwapActionButton } from 'ui/src/components/icons/SwapActionButton' +import { Button, Flex, FlexProps, SegmentedControl, Text, TouchableArea, useSporeColors } from 'ui/src' +import { ArrowUpDown } from 'ui/src/components/icons/ArrowUpDown' import { fonts } from 'ui/src/theme' import { AmountInput, numericInputRegex } from 'uniswap/src/components/CurrencyInputPanel/AmountInput' import { Trans, useTranslation } from 'uniswap/src/i18n' +import { areCurrenciesEqual } from 'uniswap/src/utils/currencyId' import { NumberType, useFormatter } from 'utils/formatNumbers' enum RangeSelectionInput { @@ -23,6 +27,131 @@ enum RangeSelection { CUSTOM = 'CUSTOM', } +function DisplayCurrentPrice({ price }: { price?: Price }) { + const [priceInverted, setPriceInverted] = useState(false) + const { formatPrice } = useFormatter() + const { price: currentPrice, quote, base } = calculateInvertedPrice({ price, invert: priceInverted }) + + const invertPrice = useCallback(() => { + setPriceInverted((prev) => !prev) + }, [setPriceInverted]) + + return ( + + + + + + + + + + + + ) +} + +const InitialPriceInput = () => { + const colors = useSporeColors() + + const { derivedPositionInfo } = useCreatePositionContext() + const { + priceRangeState: { initialPrice, initialPriceInverted }, + setPriceRangeState, + derivedPriceRangeInfo, + } = usePriceRangeContext() + + const [token0, token1] = derivedPositionInfo.currencies + const [initialPriceBaseToken, initialPriceQuoteToken] = getInvertedTuple( + derivedPositionInfo.currencies, + initialPriceInverted, + ) + const price = derivedPriceRangeInfo.price + + const controlOptions = useMemo(() => { + return [{ value: token0?.symbol ?? '' }, { value: token1?.symbol ?? '' }] + }, [token0?.symbol, token1?.symbol]) + + const handleSelectInitialPriceBaseToken = useCallback( + (option: string) => { + if (option === token0?.symbol) { + setPriceRangeState((prevState) => ({ ...prevState, initialPriceInverted: false })) + } else { + setPriceRangeState((prevState) => ({ ...prevState, initialPriceInverted: true })) + } + }, + [token0?.symbol, setPriceRangeState], + ) + + return ( + + + + + + + + + + + + + + setPriceRangeState((prev) => ({ ...prev, initialPrice: text }))} + /> + + + + + + + ) +} + function RangeControl({ value, active }: { value: string; active: boolean }) { return ( @@ -40,22 +169,25 @@ function RangeInput({ input, decrement, increment, + showIncrementButtons = true, }: { value: string input: RangeSelectionInput decrement: () => string increment: () => string + showIncrementButtons?: boolean }) { const colors = useSporeColors() const { t } = useTranslation() + const { derivedPositionInfo } = useCreatePositionContext() const { + priceRangeState: { priceInverted }, setPriceRangeState, - derivedPriceRangeInfo: { baseAndQuoteTokens }, } = usePriceRangeContext() const [typedValue, setTypedValue] = useState('') - const [baseToken, quoteToken] = baseAndQuoteTokens ?? [undefined, undefined] + const [baseCurrency, quoteCurrency] = getInvertedTuple(derivedPositionInfo.currencies, priceInverted) const [displayUserTypedValue, setDisplayUserTypedValue] = useState(false) const handlePriceRangeInput = useCallback( @@ -106,7 +238,7 @@ function RangeInput({ fontFamily="$heading" fontSize={fonts.heading3.fontSize} fontWeight={fonts.heading3.fontWeight} - maxDecimals={quoteToken?.decimals ?? 18} + maxDecimals={quoteCurrency?.decimals ?? 18} overflow="visible" placeholder="0" placeholderTextColor={colors.neutral3.val} @@ -121,54 +253,75 @@ function RangeInput({ - - - - + {showIncrementButtons && ( + + + + + )} ) } +export const SelectPriceRangeStepV2 = ({ onContinue, ...rest }: { onContinue: () => void } & FlexProps) => { + return ( + + + + + + ) +} + export const SelectPriceRangeStep = ({ onContinue, ...rest }: { onContinue: () => void } & FlexProps) => { const { t } = useTranslation() - const { formatPrice } = useFormatter() const { positionState: { fee }, derivedPositionInfo, } = useCreatePositionContext() - const { - priceRangeState: { fullRange }, - setPriceRangeState, - derivedPriceRangeInfo: { - baseAndQuoteTokens, - price, - prices, - pricesAtTicks, - ticks, - isSorted, - ticksAtLimit, - invalidPrice, - invalidRange, - }, - } = usePriceRangeContext() + const { priceRangeState, setPriceRangeState, derivedPriceRangeInfo } = usePriceRangeContext() - const { TOKEN0: token0, TOKEN1: token1 } = derivedPositionInfo.currencies - const [baseToken, quoteToken] = baseAndQuoteTokens ?? [undefined, undefined] + const [token0, token1] = derivedPositionInfo.currencies + const [baseCurrency, quoteCurrency] = getInvertedTuple(derivedPositionInfo.currencies, priceRangeState.priceInverted) + const creatingPoolOrPair = derivedPositionInfo.creatingPoolOrPair const controlOptions = useMemo(() => { return [{ value: token0?.symbol ?? '' }, { value: token1?.symbol ?? '' }] - }, [token0?.symbol, token1?.symbol]) + }, [token0, token1]) const handleSelectToken = useCallback( (option: string) => { @@ -181,11 +334,27 @@ export const SelectPriceRangeStep = ({ onContinue, ...rest }: { onContinue: () = [token0?.symbol, setPriceRangeState], ) + const price = derivedPriceRangeInfo.price + const { ticks, isSorted, prices, pricesAtTicks, ticksAtLimit, invalidPrice, invalidRange } = useMemo(() => { + if (derivedPriceRangeInfo.protocolVersion === ProtocolVersion.V2) { + return { + ticks: undefined, + isSorted: false, + prices: undefined, + pricesAtTicks: undefined, + ticksAtLimit: [false, false], + invalidPrice: false, + invalidRange: false, + } + } + + return derivedPriceRangeInfo + }, [derivedPriceRangeInfo]) const pool = derivedPositionInfo.protocolVersion === ProtocolVersion.V3 ? derivedPositionInfo.pool : undefined const { getDecrementLower, getIncrementLower, getDecrementUpper, getIncrementUpper } = useRangeHopCallbacks( - baseToken ?? undefined, - quoteToken ?? undefined, - fee, + baseCurrency ?? undefined, + quoteCurrency ?? undefined, + fee.feeAmount, ticks?.[0], ticks?.[1], pool, @@ -196,18 +365,31 @@ export const SelectPriceRangeStep = ({ onContinue, ...rest }: { onContinue: () = if (option === RangeSelection.FULL) { setPriceRangeState((prevState) => ({ ...prevState, + minPrice: '', + maxPrice: '', fullRange: true, })) } else { - setPriceRangeState((prevState) => ({ ...prevState, fullRange: false })) + setPriceRangeState((prevState) => ({ + ...prevState, + minPrice: undefined, + maxPrice: undefined, + fullRange: false, + })) } }, [setPriceRangeState], ) const segmentedControlRangeOptions = [ - { display: , value: RangeSelection.FULL }, - { display: , value: RangeSelection.CUSTOM }, + { + display: , + value: RangeSelection.FULL, + }, + { + display: , + value: RangeSelection.CUSTOM, + }, ] const rangeSelectionInputValues = useMemo(() => { @@ -228,24 +410,57 @@ export const SelectPriceRangeStep = ({ onContinue, ...rest }: { onContinue: () = [setPriceRangeState], ) - const invalidState = invalidPrice || invalidRange + const invalidState = + invalidPrice || + invalidRange || + (derivedPositionInfo.creatingPoolOrPair && + (!priceRangeState.initialPrice || priceRangeState.initialPrice.length === 0)) + + if (derivedPositionInfo.protocolVersion === ProtocolVersion.V2) { + return ( + + + + + + ) + } return ( + {creatingPoolOrPair && } - + - - - - - - - - - - handleChartRangeInput(RangeSelectionInput.MIN, text)} - onRightRangeInput={(text) => handleChartRangeInput(RangeSelectionInput.MAX, text)} - interactive={true} - /> + + {!creatingPoolOrPair && ( + handleChartRangeInput(RangeSelectionInput.MIN, text)} + onRightRangeInput={(text) => handleChartRangeInput(RangeSelectionInput.MAX, text)} + interactive={true} + /> + )} + {isShowMoreFeeTiersEnabled && ( - + {feeTiers.map((feeTier) => ( ))} @@ -296,6 +342,7 @@ export function SelectTokensStep({ )} )} + + )} + + ) +} + +const chainFilterAtom = atomWithStorage('positions-chain-filter', null) +const versionFilterAtom = atomWithStorage('positions-version-filter', [ + ProtocolVersion.V4, + ProtocolVersion.V3, + ProtocolVersion.V2, +]) +const statusFilterAtom = atomWithStorage('positions-status-filter', [ + PositionStatus.IN_RANGE, + PositionStatus.OUT_OF_RANGE, +]) + export default function Positions() { - const [chainFilter, setChainFilter] = useState(null) - const [versionFilter, setVersionFilter] = useState([ - ProtocolVersion.V4, - ProtocolVersion.V3, - ProtocolVersion.V2, - ]) - const [statusFilter, setStatusFilter] = useState([ - PositionStatus.IN_RANGE, - PositionStatus.OUT_OF_RANGE, - PositionStatus.CLOSED, - ]) + const [chainFilter, setChainFilter] = useAtom(chainFilterAtom) + const [versionFilter, setVersionFilter] = useAtom(versionFilterAtom) + const [statusFilter, setStatusFilter] = useAtom(statusFilterAtom) + const [closedCTADismissed, setClosedCTADismissed] = useState(false) const navigate = useNavigate() const account = useAccount() - const { address } = account + const { address, isConnected } = account const [currentPage, setCurrentPage] = useState(0) - const { data, isLoading: positionsLoading } = useGetPositionsQuery({ - address, - chainIds: chainFilter ? [chainFilter] : undefined, - positionStatuses: statusFilter, - protocolVersions: versionFilter, - }) + const { data, isPlaceholderData } = useGetPositionsQuery( + { + address, + chainIds: chainFilter ? [chainFilter] : undefined, + positionStatuses: statusFilter, + protocolVersions: versionFilter, + }, + !isConnected, + ) const onNavigateToPosition = useCallback( (position: PositionInfo) => { @@ -71,6 +129,7 @@ export default function Positions() { return ( - - {currentPageItems.map((position, index) => { - return ( - position && ( - onNavigateToPosition(position)} - /> - ) - ) - })} - - {!data && positionsLoading && ( + {data || !account.address ? ( + currentPageItems.length > 0 ? ( + + {currentPageItems.map((position, index) => { + return ( + position && ( + onNavigateToPosition(position)} + /> + ) + ) + })} + + ) : ( + + ) + ) : ( @@ -121,6 +185,33 @@ export default function Positions() { )} + {!statusFilter.includes(PositionStatus.CLOSED) && !closedCTADismissed && account.address && ( + + + + + + + + + + + + + setClosedCTADismissed(true)} cursor="pointer"> + + + + )} {!!pageCount && pageCount > 1 && data?.positions && ( - {t('pool.top')} - - ) -} diff --git a/apps/web/src/pages/Pool/index.tsx b/apps/web/src/pages/Pool/index.tsx index 40b7a91b934..1ca54b1aa92 100644 --- a/apps/web/src/pages/Pool/index.tsx +++ b/apps/web/src/pages/Pool/index.tsx @@ -1,11 +1,38 @@ +import PROVIDE_LIQUIDITY from 'assets/images/provideLiquidity.png' +import V4_HOOK from 'assets/images/v4Hooks.png' import Positions from 'pages/Pool/Positions' -import TopPools from 'pages/Pool/TopPools' import { Navigate } from 'react-router-dom' -import { Flex } from 'ui/src' +import { Anchor, Flex, Text } from 'ui/src' +import { Arrow } from 'ui/src/components/arrow/Arrow' +import { iconSizes } from 'ui/src/theme' +import { uniswapUrls } from 'uniswap/src/constants/urls' import { FeatureFlags } from 'uniswap/src/features/gating/flags' import { useFeatureFlagWithLoading } from 'uniswap/src/features/gating/hooks' +import { useTranslation } from 'uniswap/src/i18n' + +function LearnMoreTile({ img, text, link }: { img: string; text: string; link?: string }) { + return ( + + + + {text} + + + ) +} export default function Pool() { + const { t } = useTranslation() const { value: v4Enabled, isLoading } = useFeatureFlagWithLoading(FeatureFlags.V4Everywhere) if (!isLoading && !v4Enabled) { @@ -17,12 +44,28 @@ export default function Pool() { } return ( - + - - + + {t('liquidity.learnMoreLabel')} + + + + + + + + {t('common.button.learn')} + + + + ) diff --git a/apps/web/src/pages/RemoveLiquidity/RemoveLiquidityForm.tsx b/apps/web/src/pages/RemoveLiquidity/RemoveLiquidityForm.tsx new file mode 100644 index 00000000000..a2d1d27c124 --- /dev/null +++ b/apps/web/src/pages/RemoveLiquidity/RemoveLiquidityForm.tsx @@ -0,0 +1,96 @@ +import { LiquidityModalDetailRows } from 'components/Liquidity/LiquidityModalDetailRows' +import { LiquidityPositionInfo } from 'components/Liquidity/LiquidityPositionInfo' +import { StyledPercentInput } from 'components/PercentInput' +import { DecreaseLiquidityStep, useLiquidityModalContext } from 'components/RemoveLiquidity/RemoveLiquidityModalContext' +import { useRemoveLiquidityTxContext } from 'components/RemoveLiquidity/RemoveLiquidityTxContext' +import { ClickablePill } from 'pages/Swap/Buy/PredefinedAmount' +import { NumericalInputMimic, NumericalInputSymbolContainer, NumericalInputWrapper } from 'pages/Swap/common/shared' +import { Button, Flex, Text, useSporeColors } from 'ui/src' +import { Trans, useTranslation } from 'uniswap/src/i18n' +import useResizeObserver from 'use-resize-observer' + +export function RemoveLiquidityForm() { + const hiddenObserver = useResizeObserver() + const { t } = useTranslation() + const colors = useSporeColors() + + const { percent, positionInfo, setPercent, setStep, percentInvalid } = useLiquidityModalContext() + const removeLiquidityTxContext = useRemoveLiquidityTxContext() + + const { gasFeeEstimateUSD, txContext } = removeLiquidityTxContext + + if (!positionInfo) { + throw new Error('RemoveLiquidityModal must have an initial state when opening') + } + + const { currency0Amount, currency1Amount } = positionInfo + + return ( + <> + {/* Position info */} + + + + {/* Percent input panel */} + + + + + + + { + setPercent(value) + }} + placeholder="0" + $width={percent && hiddenObserver.width ? hiddenObserver.width + 1 : undefined} + maxDecimals={1} + maxLength={2} + /> + % + {percent} + + + + {[25, 50, 75, 100].map((option) => { + const active = percent === option.toString() + const disabled = false + return ( + { + setPercent(option.toString()) + }} + $disabled={disabled} + $active={active} + customBorderColor={colors.surface3.val} + foregroundColor={colors[disabled ? 'neutral3' : active ? 'neutral1' : 'neutral2'].val} + label={option < 100 ? option + '%' : t('swap.button.max')} + px="$spacing16" + textVariant="buttonLabel2" + /> + ) + })} + + + {/* Detail rows */} + + + + ) +} diff --git a/apps/web/src/pages/RemoveLiquidity/RemoveLiquidityModal.tsx b/apps/web/src/pages/RemoveLiquidity/RemoveLiquidityModal.tsx index aa395cec67d..d53098a2251 100644 --- a/apps/web/src/pages/RemoveLiquidity/RemoveLiquidityModal.tsx +++ b/apps/web/src/pages/RemoveLiquidity/RemoveLiquidityModal.tsx @@ -1,170 +1,44 @@ // eslint-disable-next-line no-restricted-imports -import { LiquidityModalDetailRows } from 'components/Liquidity/LiquidityModalDetailRows' import { LiquidityModalHeader } from 'components/Liquidity/LiquidityModalHeader' -import { LiquidityPositionInfo } from 'components/Liquidity/LiquidityPositionInfo' -import { TokenInfo } from 'components/Liquidity/TokenInfo' -import { StyledPercentInput } from 'components/PercentInput' -import useSelectChain from 'hooks/useSelectChain' import { + DecreaseLiquidityStep, RemoveLiquidityModalContextProvider, useLiquidityModalContext, -} from 'pages/RemoveLiquidity/RemoveLiquidityModalContext' -import { - RemoveLiquidityTxContextProvider, - useRemoveLiquidityTxContext, -} from 'pages/RemoveLiquidity/RemoveLiquidityTxContext' -import { ClickablePill } from 'pages/Swap/Buy/PredefinedAmount' -import { NumericalInputMimic, NumericalInputSymbolContainer, NumericalInputWrapper } from 'pages/Swap/common/shared' -import { useState } from 'react' -import { useDispatch } from 'react-redux' +} from 'components/RemoveLiquidity/RemoveLiquidityModalContext' +import { RemoveLiquidityReview } from 'components/RemoveLiquidity/RemoveLiquidityReview' +import { RemoveLiquidityTxContextProvider } from 'components/RemoveLiquidity/RemoveLiquidityTxContext' +import { RemoveLiquidityForm } from 'pages/RemoveLiquidity/RemoveLiquidityForm' import { useCloseModal } from 'state/application/hooks' -import { liquiditySaga } from 'state/sagas/liquidity/liquiditySaga' -import { Button, Flex, Text, useSporeColors } from 'ui/src' -import { ProgressIndicator } from 'uniswap/src/components/ConfirmSwapModal/ProgressIndicator' +import { Flex, HeightAnimator } from 'ui/src' import { Modal } from 'uniswap/src/components/modals/Modal' -import { useAccountMeta } from 'uniswap/src/contexts/UniswapContext' -import { AccountType } from 'uniswap/src/features/accounts/types' import { ModalName } from 'uniswap/src/features/telemetry/constants' -import { isValidLiquidityTxContext } from 'uniswap/src/features/transactions/liquidity/types' -import { useUSDCValue } from 'uniswap/src/features/transactions/swap/hooks/useUSDCPrice' -import { TransactionStep } from 'uniswap/src/features/transactions/swap/types/steps' -import { Trans, useTranslation } from 'uniswap/src/i18n' -import useResizeObserver from 'use-resize-observer' -import { useAccount } from 'wagmi' +import { useTranslation } from 'uniswap/src/i18n' function RemoveLiquidityModalInner() { const closeModal = useCloseModal(ModalName.RemoveLiquidity) - const hiddenObserver = useResizeObserver() const { t } = useTranslation() - const colors = useSporeColors() - const { percent, positionInfo, setPercent, percentInvalid } = useLiquidityModalContext() - const removeLiquidityTxContext = useRemoveLiquidityTxContext() - const { gasFeeEstimateUSD, txContext } = removeLiquidityTxContext - const [steps, setSteps] = useState([]) - const [currentStep, setCurrentStep] = useState<{ step: TransactionStep; accepted: boolean } | undefined>() - const selectChain = useSelectChain() - const startChainId = useAccount().chainId - const account = useAccountMeta() - const dispatch = useDispatch() - const currency0FiatAmount = useUSDCValue(positionInfo?.currency0Amount) ?? undefined - const currency1FiatAmount = useUSDCValue(positionInfo?.currency1Amount) ?? undefined - - const onFailure = () => { - setCurrentStep(undefined) - } - - const onDecreaseLiquidity = () => { - const isValidTx = isValidLiquidityTxContext(txContext) - if (!account || account?.type !== AccountType.SignerMnemonic || !isValidTx) { - return - } - dispatch( - liquiditySaga.actions.trigger({ - selectChain, - startChainId, - account, - liquidityTxContext: txContext, - setCurrentStep, - setSteps, - onSuccess: closeModal, - onFailure, - }), - ) + const { step, setStep } = useLiquidityModalContext() + + let modalContent + switch (step) { + case DecreaseLiquidityStep.Input: + modalContent = + break + case DecreaseLiquidityStep.Review: + modalContent = + break } - if (!positionInfo) { - throw new Error('RemoveLiquidityModal must have an initial state when opening') - } - - const { currency0Amount, currency1Amount } = positionInfo - return ( - - - - - {currentStep ? ( - <> - - - - {t('common.and')} - - - - - - ) : ( - <> - {/* Position info */} - - - - {/* Percent input panel */} - - - - - - - { - setPercent(value) - }} - placeholder="0" - $width={percent && hiddenObserver.width ? hiddenObserver.width + 1 : undefined} - maxDecimals={1} - maxLength={2} - /> - % - {percent} - - - - {[25, 50, 75, 100].map((option) => { - const active = percent === option.toString() - const disabled = false - return ( - { - setPercent(option.toString()) - }} - $disabled={disabled} - $active={active} - customBorderColor={colors.surface3.val} - foregroundColor={colors[disabled ? 'neutral3' : active ? 'neutral1' : 'neutral2'].val} - label={option < 100 ? option + '%' : t('swap.button.max')} - px="$spacing16" - textVariant="buttonLabel2" - /> - ) - })} - - - {/* Detail rows */} - - - - )} + + + setStep(DecreaseLiquidityStep.Input) : undefined} + /> + {modalContent} ) } diff --git a/apps/web/src/pages/RemoveLiquidity/V3.tsx b/apps/web/src/pages/RemoveLiquidity/V3.tsx index ce89b465cef..eab563cd4f3 100644 --- a/apps/web/src/pages/RemoveLiquidity/V3.tsx +++ b/apps/web/src/pages/RemoveLiquidity/V3.tsx @@ -15,7 +15,7 @@ import TransactionConfirmationModal, { ConfirmationModalContent } from 'componen import { AutoColumn } from 'components/deprecated/Column' import { AutoRow, RowBetween, RowFixed } from 'components/deprecated/Row' import { Break } from 'components/earn/styled' -import { useIsSupportedChainId } from 'constants/chains' +import { chainIdToBackendChain, useIsSupportedChainId } from 'constants/chains' import { useAccount } from 'hooks/useAccount' import { useV3NFTPositionManagerContract } from 'hooks/useContract' import useDebouncedChangeHandler from 'hooks/useDebouncedChangeHandler' @@ -36,8 +36,11 @@ import { useUserSlippageToleranceWithDefault } from 'state/user/hooks' import { ThemedText } from 'theme/components' import { Switch, Text } from 'ui/src' import { WRAPPED_NATIVE_CURRENCY } from 'uniswap/src/constants/tokens' +import { FeatureFlags } from 'uniswap/src/features/gating/flags' +import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import { Trans } from 'uniswap/src/i18n' +import { UniverseChainId } from 'uniswap/src/types/chains' import { logger } from 'utilities/src/logger/logger' import { useTrace } from 'utilities/src/telemetry/trace/TraceContext' import { calculateGasMargin } from 'utils/calculateGasMargin' @@ -62,6 +65,11 @@ export default function RemoveLiquidityV3() { }, [tokenId]) const { position, loading } = useV3PositionFromTokenId(parsedTokenId ?? undefined) + const isV4EverywhereEnabled = useFeatureFlag(FeatureFlags.V4Everywhere) + if (isV4EverywhereEnabled) { + const chainName = chainIdToBackendChain({ chainId: chainId ?? UniverseChainId.Mainnet }).toLowerCase() + return + } if (parsedTokenId === null || parsedTokenId.eq(0)) { return } diff --git a/apps/web/src/pages/RemoveLiquidity/index.tsx b/apps/web/src/pages/RemoveLiquidity/index.tsx index f1f91aeec0e..a60cefdc694 100644 --- a/apps/web/src/pages/RemoveLiquidity/index.tsx +++ b/apps/web/src/pages/RemoveLiquidity/index.tsx @@ -24,7 +24,7 @@ import { V2Unsupported } from 'components/V2Unsupported' import { AutoColumn, ColumnCenter } from 'components/deprecated/Column' import Row, { RowBetween, RowFixed } from 'components/deprecated/Row' import { Dots } from 'components/swap/styled' -import { useIsSupportedChainId } from 'constants/chains' +import { chainIdToBackendChain, useIsSupportedChainId } from 'constants/chains' import { useCurrency } from 'hooks/Tokens' import { useAccount } from 'hooks/useAccount' import { ApprovalState, useApproveCallback } from 'hooks/useApproveCallback' @@ -40,7 +40,7 @@ import { PositionPageUnsupportedContent } from 'pages/LegacyPool/PositionPage' import { ClickableText, MaxButton, Wrapper } from 'pages/LegacyPool/styled' import { useCallback, useMemo, useState } from 'react' import { ArrowDown, Plus } from 'react-feather' -import { useNavigate, useParams } from 'react-router-dom' +import { Navigate, useNavigate, useParams } from 'react-router-dom' import { Field } from 'state/burn/actions' import { useBurnActionHandlers, useBurnState, useDerivedBurnInfo } from 'state/burn/hooks' import { useTransactionAdder } from 'state/transactions/hooks' @@ -49,9 +49,12 @@ import { useUserSlippageToleranceWithDefault } from 'state/user/hooks' import { StyledInternalLink, ThemedText } from 'theme/components' import { Text } from 'ui/src' import { WRAPPED_NATIVE_CURRENCY } from 'uniswap/src/constants/tokens' +import { FeatureFlags } from 'uniswap/src/features/gating/flags' +import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import Trace from 'uniswap/src/features/telemetry/Trace' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import { Trans } from 'uniswap/src/i18n' +import { UniverseChainId } from 'uniswap/src/types/chains' import { logger } from 'utilities/src/logger/logger' import { useTrace } from 'utilities/src/telemetry/trace/TraceContext' import { calculateGasMargin } from 'utils/calculateGasMargin' @@ -62,9 +65,17 @@ const DEFAULT_REMOVE_LIQUIDITY_SLIPPAGE_TOLERANCE = new Percent(50, 10_000) export default function RemoveLiquidityWrapper() { const { chainId } = useAccount() + const isV4EverywhereEnabled = useFeatureFlag(FeatureFlags.V4Everywhere) const isSupportedChain = useIsSupportedChainId(chainId) const { currencyIdA, currencyIdB } = useParams<{ currencyIdA: string; currencyIdB: string }>() const [currencyA, currencyB] = [useCurrency(currencyIdA) ?? undefined, useCurrency(currencyIdB) ?? undefined] + + if (isV4EverywhereEnabled) { + // TODO(WEB-5361): prefill poolId from legacy URL /remove/ETH/0x123 + const chainName = chainIdToBackendChain({ chainId: chainId ?? UniverseChainId.Mainnet }).toLowerCase() + return + } + if (isSupportedChain && currencyA !== currencyB) { return } else { diff --git a/apps/web/src/pages/RouteDefinitions.tsx b/apps/web/src/pages/RouteDefinitions.tsx index c9fc2fd50d5..6fb90408e70 100644 --- a/apps/web/src/pages/RouteDefinitions.tsx +++ b/apps/web/src/pages/RouteDefinitions.tsx @@ -23,13 +23,21 @@ const MigrateV2Pair = lazy(() => import('pages/MigrateV2/MigrateV2Pair')) const MigrateV3 = lazy(() => import('pages/MigrateV3')) const NotFound = lazy(() => import('pages/NotFound')) const Pool = lazy(() => import('pages/Pool')) -const LegacyPool = lazy(() => import('pages/LegacyPool')) -const LegacyPositionPage = lazy(() => import('pages/LegacyPool/PositionPage')) +const LegacyPoolRedirects = lazy(() => + import('pages/LegacyPool/redirects').then((module) => ({ default: module.LegacyPoolRedirects })), +) +const PoolFinderRedirects = lazy(() => + import('pages/LegacyPool/redirects').then((module) => ({ default: module.PoolFinderRedirects })), +) +const LegacyPoolV2Redirects = lazy(() => + import('pages/LegacyPool/redirects').then((module) => ({ default: module.LegacyPoolV2Redirects })), +) +const LegacyPositionPageRedirects = lazy(() => + import('pages/LegacyPool/redirects').then((module) => ({ default: module.LegacyPositionPageRedirects })), +) const PositionPage = lazy(() => import('pages/Pool/Positions/PositionPage')) const V2PositionPage = lazy(() => import('pages/Pool/Positions/V2PositionPage')) -const LegacyPoolV2 = lazy(() => import('pages/LegacyPool/v2')) const PoolDetails = lazy(() => import('pages/PoolDetails')) -const PoolFinder = lazy(() => import('pages/PoolFinder')) const RemoveLiquidity = lazy(() => import('pages/RemoveLiquidity')) const RemoveLiquidityV3 = lazy(() => import('pages/RemoveLiquidity/V3')) const TokenDetails = lazy(() => import('pages/TokenDetails')) @@ -240,7 +248,7 @@ export const routes: RouteDefinition[] = [ getDescription: getPositionPageDescription, }), createRouteDefinition({ - path: '/migrate/v3/:tokenId', + path: '/migrate/v3/:chainName/:tokenId', getElement: () => , getTitle: () => StaticTitlesAndDescriptions.MigrateTitleV3, getDescription: () => StaticTitlesAndDescriptions.MigrateDescriptionV4, @@ -248,49 +256,49 @@ export const routes: RouteDefinition[] = [ // Legacy pool routes createRouteDefinition({ path: '/pool', - getElement: () => , + getElement: () => , getTitle: getPositionPageTitle, getDescription: getPositionPageDescription, }), createRouteDefinition({ path: '/pool/v2/find', - getElement: () => , - getTitle: () => t('title.importLiquidityv2'), - getDescription: () => t('title.useImportTool'), + getElement: () => , + getTitle: getPositionPageDescription, + getDescription: getPositionPageDescription, }), createRouteDefinition({ path: '/pool/v2', - getElement: () => , + getElement: () => , getTitle: getPositionPageTitle, getDescription: getPositionPageDescription, }), createRouteDefinition({ path: '/pool/:tokenId', - getElement: () => , + getElement: () => , getTitle: getPositionPageTitle, getDescription: getPositionPageDescription, }), createRouteDefinition({ path: '/pools/v2/find', - getElement: () => , - getTitle: () => t('title.importLiquidityv2'), - getDescription: () => t('title.useImportTool'), + getElement: () => , + getTitle: getPositionPageTitle, + getDescription: getPositionPageDescription, }), createRouteDefinition({ path: '/pools/v2', - getElement: () => , + getElement: () => , getTitle: getPositionPageTitle, getDescription: getPositionPageDescription, }), createRouteDefinition({ path: '/pools', - getElement: () => , + getElement: () => , getTitle: getPositionPageTitle, getDescription: getPositionPageDescription, }), createRouteDefinition({ path: '/pools/:tokenId', - getElement: () => , + getElement: () => , getTitle: getPositionPageTitle, getDescription: getPositionPageDescription, }), diff --git a/apps/web/src/pages/Swap/Buy/BuyFormContext.tsx b/apps/web/src/pages/Swap/Buy/BuyFormContext.tsx index 7a2a756937c..8f75a95209b 100644 --- a/apps/web/src/pages/Swap/Buy/BuyFormContext.tsx +++ b/apps/web/src/pages/Swap/Buy/BuyFormContext.tsx @@ -3,7 +3,7 @@ import { useUSDTokenUpdater } from 'hooks/useUSDTokenUpdater' import { useFiatOnRampSupportedTokens, useMeldFiatCurrencyInfo } from 'pages/Swap/Buy/hooks' import { formatFiatOnRampFiatAmount } from 'pages/Swap/Buy/shared' import { Dispatch, PropsWithChildren, SetStateAction, createContext, useContext, useMemo, useState } from 'react' -import { buildCurrencyInfo } from 'uniswap/src/constants/routing' +import { buildPartialCurrencyInfo } from 'uniswap/src/constants/routing' import { nativeOnChain } from 'uniswap/src/constants/tokens' import { useFiatOnRampAggregatorCountryListQuery, @@ -59,7 +59,7 @@ type BuyFormContextType = { derivedBuyFormInfo: BuyInfo } -export const ethCurrencyInfo = buildCurrencyInfo(nativeOnChain(UniverseChainId.Mainnet)) +export const ethCurrencyInfo = buildPartialCurrencyInfo(nativeOnChain(UniverseChainId.Mainnet)) const DEFAULT_BUY_FORM_STATE: BuyFormState = { inputAmount: '', quoteCurrency: { diff --git a/apps/web/src/pages/Swap/Buy/CountryListModal.tsx b/apps/web/src/pages/Swap/Buy/CountryListModal.tsx index c43160e22d3..ffe8c00b136 100644 --- a/apps/web/src/pages/Swap/Buy/CountryListModal.tsx +++ b/apps/web/src/pages/Swap/Buy/CountryListModal.tsx @@ -1,4 +1,4 @@ -import { scrollbarStyle } from 'components/SearchModal/CurrencyList/index.css' +import { ScrollBarStyles } from 'components/Common/styles' import { SearchInput } from 'components/SearchModal/styled' import { CountryListRow } from 'pages/Swap/Buy/CountryListRow' import { ContentWrapper } from 'pages/Swap/Buy/shared' @@ -90,7 +90,6 @@ export function CountryListModal({ {({ height }: { height: number }) => (
data[index]?.countryCode} + {...ScrollBarStyles} > {({ style, data, index }) => ( (
data[index]?.meldCurrencyCode ?? index} + {...ScrollBarStyles} > {({ style, data, index }: { data: FiatOnRampCurrency[]; index: number; style: CSSProperties }) => { const currencyInfo = data[index].currencyInfo diff --git a/apps/web/src/pages/Swap/Limit/__snapshots__/LimitPriceError.test.tsx.snap b/apps/web/src/pages/Swap/Limit/__snapshots__/LimitPriceError.test.tsx.snap index f6695662439..3a7866c95db 100644 --- a/apps/web/src/pages/Swap/Limit/__snapshots__/LimitPriceError.test.tsx.snap +++ b/apps/web/src/pages/Swap/Limit/__snapshots__/LimitPriceError.test.tsx.snap @@ -669,7 +669,7 @@ exports[`LimitPriceError renders the limit price error correctly, inverted true
- Buying USDC above market price.error + Buying USDC above market price
- Buying USDC above market price.error + Buying USDC above market price
theme.neutral3}; - transform: rotate(90deg); -` - const MaxButton = styled(ButtonLight)` position: absolute; right: 40px; @@ -156,7 +149,7 @@ const AlternateCurrencyDisplay = ({ disabled, onToggle }: { disabled: boolean; o {formattedAlternateCurrency} - + ) diff --git a/apps/web/src/pages/Swap/Send/__snapshots__/SendCurrencyInputForm.test.tsx.snap b/apps/web/src/pages/Swap/Send/__snapshots__/SendCurrencyInputForm.test.tsx.snap index 019b8f55a53..54989a08537 100644 --- a/apps/web/src/pages/Swap/Send/__snapshots__/SendCurrencyInputForm.test.tsx.snap +++ b/apps/web/src/pages/Swap/Send/__snapshots__/SendCurrencyInputForm.test.tsx.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`SendCurrencyInputform renders input in fiat correctly 1`] = ` -.c23 { +.c22 { display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; @@ -12,29 +12,29 @@ exports[`SendCurrencyInputform renders input in fiat correctly 1`] = ` left: 0; } -.c23 img { +.c22 img { width: 17px; height: 36px; object-fit: cover; } -.c23 img:first-child { +.c22 img:first-child { border-radius: 18px 0 0 18px; object-position: 0 0; } -.c23 img:last-child { +.c22 img:last-child { border-radius: 0 18px 18px 0; object-position: 100% 0; } -.c24 { +.c23 { width: 18px; height: 36px; border-radius: 50%; } -.c22 { +.c21 { display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; @@ -54,7 +54,7 @@ exports[`SendCurrencyInputform renders input in fiat correctly 1`] = ` min-width: 0; } -.c19 { +.c18 { box-sizing: border-box; margin: 0; min-width: 0; @@ -96,7 +96,7 @@ exports[`SendCurrencyInputform renders input in fiat correctly 1`] = ` gap: 4px; } -.c20 { +.c19 { width: 100%; display: -webkit-box; display: -webkit-flex; @@ -114,7 +114,7 @@ exports[`SendCurrencyInputform renders input in fiat correctly 1`] = ` gap: 12px; } -.c26 { +.c25 { width: 100%; display: -webkit-box; display: -webkit-flex; @@ -132,7 +132,7 @@ exports[`SendCurrencyInputform renders input in fiat correctly 1`] = ` gap: 4px; } -.c17 { +.c16 { -webkit-box-pack: justify; -webkit-justify-content: space-between; -ms-flex-pack: justify; @@ -147,7 +147,7 @@ exports[`SendCurrencyInputform renders input in fiat correctly 1`] = ` letter-spacing: -0.01em; } -.c25 { +.c24 { color: #222222; -webkit-letter-spacing: -0.01em; -moz-letter-spacing: -0.01em; @@ -289,7 +289,7 @@ exports[`SendCurrencyInputform renders input in fiat correctly 1`] = ` gap: 1px; } -.c16 { +.c15 { display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; @@ -309,7 +309,7 @@ exports[`SendCurrencyInputform renders input in fiat correctly 1`] = ` position: relative; } -.c18 { +.c17 { -webkit-text-decoration: none; text-decoration: none; cursor: pointer; @@ -317,11 +317,11 @@ exports[`SendCurrencyInputform renders input in fiat correctly 1`] = ` transition-duration: 125ms; } -.c18:hover { +.c17:hover { opacity: 0.6; } -.c18:active { +.c17:active { opacity: 0.4; } @@ -348,16 +348,7 @@ exports[`SendCurrencyInputform renders input in fiat correctly 1`] = ` left: 16px; } -.c15 { - width: 16px; - height: 16px; - fill: #CECECE; - -webkit-transform: rotate(90deg); - -ms-transform: rotate(90deg); - transform: rotate(90deg); -} - -.c27 { +.c26 { -webkit-text-decoration: none; text-decoration: none; cursor: pointer; @@ -367,20 +358,20 @@ exports[`SendCurrencyInputform renders input in fiat correctly 1`] = ` height: 8px; } -.c27:hover { +.c26:hover { opacity: 0.6; } -.c27:active { +.c26:active { opacity: 0.4; } -.c27 path { +.c26 path { stroke: #CECECE; stroke-width: 2px; } -.c21 { +.c20 { -webkit-text-decoration: none; text-decoration: none; cursor: pointer; @@ -388,11 +379,11 @@ exports[`SendCurrencyInputform renders input in fiat correctly 1`] = ` transition-duration: 125ms; } -.c21:hover { +.c20:hover { opacity: 0.6; } -.c21:active { +.c20:active { opacity: 0.4; } @@ -477,51 +468,52 @@ exports[`SendCurrencyInputform renders input in fiat correctly 1`] = ` 100.00 DAI
@@ -529,19 +521,19 @@ exports[`SendCurrencyInputform renders input in fiat correctly 1`] = `
DAI
@@ -549,7 +541,7 @@ exports[`SendCurrencyInputform renders input in fiat correctly 1`] = `
dropdown.svg @@ -572,7 +564,7 @@ exports[`SendCurrencyInputform renders input in fiat correctly 1`] = ` `; exports[`SendCurrencyInputform renders input in token amount correctly 1`] = ` -.c22 { +.c21 { display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; @@ -583,29 +575,29 @@ exports[`SendCurrencyInputform renders input in token amount correctly 1`] = ` left: 0; } -.c22 img { +.c21 img { width: 17px; height: 36px; object-fit: cover; } -.c22 img:first-child { +.c21 img:first-child { border-radius: 18px 0 0 18px; object-position: 0 0; } -.c22 img:last-child { +.c21 img:last-child { border-radius: 0 18px 18px 0; object-position: 100% 0; } -.c23 { +.c22 { width: 18px; height: 36px; border-radius: 50%; } -.c21 { +.c20 { display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; @@ -625,7 +617,7 @@ exports[`SendCurrencyInputform renders input in token amount correctly 1`] = ` min-width: 0; } -.c18 { +.c17 { box-sizing: border-box; margin: 0; min-width: 0; @@ -667,7 +659,7 @@ exports[`SendCurrencyInputform renders input in token amount correctly 1`] = ` gap: 4px; } -.c19 { +.c18 { width: 100%; display: -webkit-box; display: -webkit-flex; @@ -685,7 +677,7 @@ exports[`SendCurrencyInputform renders input in token amount correctly 1`] = ` gap: 12px; } -.c25 { +.c24 { width: 100%; display: -webkit-box; display: -webkit-flex; @@ -703,7 +695,7 @@ exports[`SendCurrencyInputform renders input in token amount correctly 1`] = ` gap: 4px; } -.c16 { +.c15 { -webkit-box-pack: justify; -webkit-justify-content: space-between; -ms-flex-pack: justify; @@ -718,7 +710,7 @@ exports[`SendCurrencyInputform renders input in token amount correctly 1`] = ` letter-spacing: -0.01em; } -.c24 { +.c23 { color: #222222; -webkit-letter-spacing: -0.01em; -moz-letter-spacing: -0.01em; @@ -849,7 +841,7 @@ exports[`SendCurrencyInputform renders input in token amount correctly 1`] = ` gap: 1px; } -.c15 { +.c14 { display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; @@ -869,7 +861,7 @@ exports[`SendCurrencyInputform renders input in token amount correctly 1`] = ` position: relative; } -.c17 { +.c16 { -webkit-text-decoration: none; text-decoration: none; cursor: pointer; @@ -877,11 +869,11 @@ exports[`SendCurrencyInputform renders input in token amount correctly 1`] = ` transition-duration: 125ms; } -.c17:hover { +.c16:hover { opacity: 0.6; } -.c17:active { +.c16:active { opacity: 0.4; } @@ -908,16 +900,7 @@ exports[`SendCurrencyInputform renders input in token amount correctly 1`] = ` left: 16px; } -.c14 { - width: 16px; - height: 16px; - fill: #CECECE; - -webkit-transform: rotate(90deg); - -ms-transform: rotate(90deg); - transform: rotate(90deg); -} - -.c26 { +.c25 { -webkit-text-decoration: none; text-decoration: none; cursor: pointer; @@ -927,20 +910,20 @@ exports[`SendCurrencyInputform renders input in token amount correctly 1`] = ` height: 8px; } -.c26:hover { +.c25:hover { opacity: 0.6; } -.c26:active { +.c25:active { opacity: 0.4; } -.c26 path { +.c25 path { stroke: #CECECE; stroke-width: 2px; } -.c20 { +.c19 { -webkit-text-decoration: none; text-decoration: none; cursor: pointer; @@ -948,11 +931,11 @@ exports[`SendCurrencyInputform renders input in token amount correctly 1`] = ` transition-duration: 125ms; } -.c20:hover { +.c19:hover { opacity: 0.6; } -.c20:active { +.c19:active { opacity: 0.4; } @@ -1032,51 +1015,52 @@ exports[`SendCurrencyInputform renders input in token amount correctly 1`] = ` $100.00 USD
@@ -1084,19 +1068,19 @@ exports[`SendCurrencyInputform renders input in token amount correctly 1`] = `
DAI
@@ -1104,7 +1088,7 @@ exports[`SendCurrencyInputform renders input in token amount correctly 1`] = `
dropdown.svg @@ -1127,7 +1111,7 @@ exports[`SendCurrencyInputform renders input in token amount correctly 1`] = ` `; exports[`SendCurrencyInputform should render placeholder values 1`] = ` -.c23 { +.c22 { display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; @@ -1138,29 +1122,29 @@ exports[`SendCurrencyInputform should render placeholder values 1`] = ` left: 0; } -.c23 img { +.c22 img { width: 17px; height: 36px; object-fit: cover; } -.c23 img:first-child { +.c22 img:first-child { border-radius: 18px 0 0 18px; object-position: 0 0; } -.c23 img:last-child { +.c22 img:last-child { border-radius: 0 18px 18px 0; object-position: 100% 0; } -.c24 { +.c23 { width: 18px; height: 36px; border-radius: 50%; } -.c22 { +.c21 { display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; @@ -1180,7 +1164,7 @@ exports[`SendCurrencyInputform should render placeholder values 1`] = ` min-width: 0; } -.c19 { +.c18 { box-sizing: border-box; margin: 0; min-width: 0; @@ -1222,7 +1206,7 @@ exports[`SendCurrencyInputform should render placeholder values 1`] = ` gap: 4px; } -.c20 { +.c19 { width: 100%; display: -webkit-box; display: -webkit-flex; @@ -1240,7 +1224,7 @@ exports[`SendCurrencyInputform should render placeholder values 1`] = ` gap: 12px; } -.c26 { +.c25 { width: 100%; display: -webkit-box; display: -webkit-flex; @@ -1258,7 +1242,7 @@ exports[`SendCurrencyInputform should render placeholder values 1`] = ` gap: 4px; } -.c17 { +.c16 { -webkit-box-pack: justify; -webkit-justify-content: space-between; -ms-flex-pack: justify; @@ -1273,7 +1257,7 @@ exports[`SendCurrencyInputform should render placeholder values 1`] = ` letter-spacing: -0.01em; } -.c25 { +.c24 { color: #222222; -webkit-letter-spacing: -0.01em; -moz-letter-spacing: -0.01em; @@ -1416,7 +1400,7 @@ exports[`SendCurrencyInputform should render placeholder values 1`] = ` gap: 1px; } -.c16 { +.c15 { display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; @@ -1436,7 +1420,7 @@ exports[`SendCurrencyInputform should render placeholder values 1`] = ` position: relative; } -.c18 { +.c17 { -webkit-text-decoration: none; text-decoration: none; cursor: pointer; @@ -1444,11 +1428,11 @@ exports[`SendCurrencyInputform should render placeholder values 1`] = ` transition-duration: 125ms; } -.c18:hover { +.c17:hover { opacity: 0.6; } -.c18:active { +.c17:active { opacity: 0.4; } @@ -1475,16 +1459,7 @@ exports[`SendCurrencyInputform should render placeholder values 1`] = ` left: 16px; } -.c15 { - width: 16px; - height: 16px; - fill: #CECECE; - -webkit-transform: rotate(90deg); - -ms-transform: rotate(90deg); - transform: rotate(90deg); -} - -.c27 { +.c26 { -webkit-text-decoration: none; text-decoration: none; cursor: pointer; @@ -1494,20 +1469,20 @@ exports[`SendCurrencyInputform should render placeholder values 1`] = ` height: 8px; } -.c27:hover { +.c26:hover { opacity: 0.6; } -.c27:active { +.c26:active { opacity: 0.4; } -.c27 path { +.c26 path { stroke: #CECECE; stroke-width: 2px; } -.c21 { +.c20 { -webkit-text-decoration: none; text-decoration: none; cursor: pointer; @@ -1515,11 +1490,11 @@ exports[`SendCurrencyInputform should render placeholder values 1`] = ` transition-duration: 125ms; } -.c21:hover { +.c20:hover { opacity: 0.6; } -.c21:active { +.c20:active { opacity: 0.4; } @@ -1602,51 +1577,52 @@ exports[`SendCurrencyInputform should render placeholder values 1`] = ` 0 DAI
@@ -1654,19 +1630,19 @@ exports[`SendCurrencyInputform should render placeholder values 1`] = `
DAI
@@ -1674,7 +1650,7 @@ exports[`SendCurrencyInputform should render placeholder values 1`] = `
dropdown.svg diff --git a/apps/web/src/pages/Swap/SwapForm.tsx b/apps/web/src/pages/Swap/SwapForm.tsx index ed575db0efa..d329ea1019f 100644 --- a/apps/web/src/pages/Swap/SwapForm.tsx +++ b/apps/web/src/pages/Swap/SwapForm.tsx @@ -51,7 +51,6 @@ import { ExternalLink, ThemedText } from 'theme/components' import { Text } from 'ui/src' import { UNIVERSE_CHAIN_INFO } from 'uniswap/src/constants/chains' import { SafetyLevel } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' -import { CurrencyInfo } from 'uniswap/src/features/dataApi/types' import Trace from 'uniswap/src/features/telemetry/Trace' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import { maybeLogFirstSwapAction } from 'uniswap/src/features/transactions/swap/utils/maybeLogFirstSwapAction' @@ -98,15 +97,22 @@ export function SwapForm({ setDismissTokenWarning(true) }, []) - // dismiss warning if all imported tokens are in active lists - const urlTokensNotInDefault = useMemo(() => { - return prefilledInputCurrencyInfo || prefilledOutputCurrencyInfo - ? [prefilledInputCurrencyInfo, prefilledOutputCurrencyInfo] - .filter((token): token is CurrencyInfo => { - return (token?.currency.isToken && token.safetyLevel !== SafetyLevel.Verified) ?? false - }) - .map((token: CurrencyInfo) => token.currency as Token) - : [] + // dismiss warning if prefilled tokens don't have warnings + const prefilledTokensWithWarnings: { field: CurrencyField; token: Token }[] = useMemo(() => { + const tokens = [] + if ( + prefilledInputCurrencyInfo?.currency.isToken && + prefilledInputCurrencyInfo.safetyLevel !== SafetyLevel.Verified + ) { + tokens.push({ field: CurrencyField.INPUT, token: prefilledInputCurrencyInfo.currency as Token }) + } + if ( + prefilledOutputCurrencyInfo?.currency.isToken && + prefilledOutputCurrencyInfo.safetyLevel !== SafetyLevel.Verified + ) { + tokens.push({ field: CurrencyField.OUTPUT, token: prefilledOutputCurrencyInfo.currency as Token }) + } + return tokens }, [prefilledInputCurrencyInfo, prefilledOutputCurrencyInfo]) const theme = useTheme() @@ -505,23 +511,25 @@ export function SwapForm({ return ( <> - 0 && !dismissTokenWarning} - token0={urlTokensNotInDefault[0]} - token1={urlTokensNotInDefault[1]} - onAcknowledge={handleConfirmTokenWarning} - onReject={() => { - setDismissTokenWarning(true) - onCurrencySelection(CurrencyField.INPUT, undefined) - onCurrencySelection(CurrencyField.OUTPUT, undefined) - }} - closeModalOnly={() => { - setDismissTokenWarning(true) - }} - onToken0BlockAcknowledged={() => onCurrencySelection(CurrencyField.INPUT, undefined)} - onToken1BlockAcknowledged={() => onCurrencySelection(CurrencyField.OUTPUT, undefined)} - showCancel={true} - /> + {prefilledTokensWithWarnings.length >= 1 && ( + = 1 && !dismissTokenWarning} + token0={prefilledTokensWithWarnings[0].token} + token1={prefilledTokensWithWarnings[1]?.token} + onAcknowledge={handleConfirmTokenWarning} + onReject={() => { + setDismissTokenWarning(true) + onCurrencySelection(CurrencyField.INPUT, undefined) + onCurrencySelection(CurrencyField.OUTPUT, undefined) + }} + closeModalOnly={() => { + setDismissTokenWarning(true) + }} + onToken0BlockAcknowledged={() => onCurrencySelection(prefilledTokensWithWarnings[0].field, undefined)} + onToken1BlockAcknowledged={() => onCurrencySelection(prefilledTokensWithWarnings[1].field, undefined)} + showCancel={true} + /> + )} {trade && showConfirm && ( (false) const closeTokenWarning = useCallback(() => setDismissTokenWarning(true), [setDismissTokenWarning]) - const urlTokensNotInDefault = useMemo( - () => - prefilledInputCurrencyInfo || prefilledOutputCurrencyInfo - ? // dismiss warning if all imported tokens are in active lists - [prefilledInputCurrencyInfo, prefilledOutputCurrencyInfo] - .filter( - (token): token is CurrencyInfo => - (token?.currency.isToken && token.safetyLevel !== SafetyLevel.Verified) ?? false, - ) - .map((token: CurrencyInfo) => token.currency as Token) - : [], - [prefilledInputCurrencyInfo, prefilledOutputCurrencyInfo], - ) - + const prefilledTokensWithWarnings: { field: CurrencyField; token: Token }[] = useMemo(() => { + const tokens = [] + if ( + prefilledInputCurrencyInfo?.currency.isToken && + prefilledInputCurrencyInfo.safetyLevel !== SafetyLevel.Verified + ) { + tokens.push({ field: CurrencyField.INPUT, token: prefilledInputCurrencyInfo.currency as Token }) + } + if ( + prefilledOutputCurrencyInfo?.currency.isToken && + prefilledOutputCurrencyInfo.safetyLevel !== SafetyLevel.Verified + ) { + tokens.push({ field: CurrencyField.OUTPUT, token: prefilledOutputCurrencyInfo.currency as Token }) + } + return tokens + }, [prefilledInputCurrencyInfo, prefilledOutputCurrencyInfo]) const { updateSwapForm } = useSwapFormContext() + const onTokenBlockAcknowledged = useCallback( + (field: CurrencyField) => { + updateSwapForm({ [field]: undefined, selectingCurrencyField: undefined }) + onCurrencyChange?.({ [field === CurrencyField.INPUT ? 'inputCurrency' : 'outputCurrency']: undefined }) + }, + [updateSwapForm, onCurrencyChange], + ) return ( <> - 0 && !dismissTokenWarning} - token0={urlTokensNotInDefault[0]} - token1={urlTokensNotInDefault[1]} - onAcknowledge={closeTokenWarning} - onReject={() => { - closeTokenWarning() - updateSwapForm({ - [CurrencyField.INPUT]: undefined, - [CurrencyField.OUTPUT]: undefined, - selectingCurrencyField: undefined, - }) - onCurrencyChange?.({ - inputCurrency: undefined, - outputCurrency: undefined, - }) - }} - closeModalOnly={closeTokenWarning} - onToken0BlockAcknowledged={() => { - updateSwapForm({ - [CurrencyField.INPUT]: undefined, - selectingCurrencyField: undefined, - }) - onCurrencyChange?.({ inputCurrency: undefined }) - }} - onToken1BlockAcknowledged={() => { - updateSwapForm({ - [CurrencyField.OUTPUT]: undefined, - selectingCurrencyField: undefined, - }) - onCurrencyChange?.({ outputCurrency: undefined }) - }} - showCancel={true} - /> + {prefilledTokensWithWarnings.length >= 1 && ( + = 1 && !dismissTokenWarning} + token0={prefilledTokensWithWarnings[0].token} + token1={prefilledTokensWithWarnings[1]?.token} + onAcknowledge={closeTokenWarning} + onReject={() => { + closeTokenWarning() + updateSwapForm({ + [CurrencyField.INPUT]: undefined, + [CurrencyField.OUTPUT]: undefined, + selectingCurrencyField: undefined, + }) + onCurrencyChange?.({ + inputCurrency: undefined, + outputCurrency: undefined, + }) + }} + closeModalOnly={closeTokenWarning} + onToken0BlockAcknowledged={() => + prefilledTokensWithWarnings.length >= 1 && onTokenBlockAcknowledged(prefilledTokensWithWarnings[0].field) + } + onToken1BlockAcknowledged={() => + prefilledTokensWithWarnings.length == 2 && onTokenBlockAcknowledged(prefilledTokensWithWarnings[1].field) + } + showCancel={true} + /> + )} {!hideHeader && ( diff --git a/apps/web/src/pages/__snapshots__/routes.test.ts.snap b/apps/web/src/pages/__snapshots__/routes.test.ts.snap index c53b37dd7ca..a02ad5455de 100644 --- a/apps/web/src/pages/__snapshots__/routes.test.ts.snap +++ b/apps/web/src/pages/__snapshots__/routes.test.ts.snap @@ -180,7 +180,7 @@ Array [ "getElement": [Function], "getTitle": [Function], "nestedPaths": Array [], - "path": "/migrate/v3/:tokenId", + "path": "/migrate/v3/:chainName/:tokenId", }, Object { "enabled": [Function], diff --git a/apps/web/src/pages/getPositionPageTitle.ts b/apps/web/src/pages/getPositionPageTitle.ts index b0621d47c1c..e3e7fc7dc35 100644 --- a/apps/web/src/pages/getPositionPageTitle.ts +++ b/apps/web/src/pages/getPositionPageTitle.ts @@ -3,26 +3,29 @@ import { t } from 'uniswap/src/i18n' export const getPositionPageTitle = (path?: string) => { const parts = path?.split('/').filter((part) => part !== '') const isV2 = parts?.find((part) => part === 'v2') + const isV3 = parts?.find((part) => part === 'v3') return t(`liquidityPool.positions.page.version.title`, { - version: isV2 ? ' (v2)' : '', + version: isV2 ? ' (v2)' : isV3 ? ' (v3)' : '', }) } export const getPositionPageDescription = (path?: string) => { const parts = path?.split('/').filter((part) => part !== '') const isV2 = parts?.find((part) => part === 'v2') + const isV3 = parts?.find((part) => part === 'v3') return t(`liquidityPool.positions.page.version.description`, { - version: isV2 ? 'v2' : 'v3', + version: isV2 ? 'v2' : isV3 ? 'v3' : 'v4', }) } export const getAddLiquidityPageTitle = (path?: string) => { const parts = path?.split('/').filter((part) => part !== '') const isV2 = parts?.find((part) => part === 'v2') + const isV3 = parts?.find((part) => part === 'v3') return t('liquidityPool.page.title', { - version: isV2 ? ' (v2)' : '', + version: isV2 ? ' (v2)' : isV3 ? ' (v3)' : '', }) } diff --git a/apps/web/src/pages/paths.test.ts b/apps/web/src/pages/paths.test.ts index 4fd81072cb3..ba131cd538d 100644 --- a/apps/web/src/pages/paths.test.ts +++ b/apps/web/src/pages/paths.test.ts @@ -67,22 +67,28 @@ describe('getExploreTitle', () => { }) describe('positionPage static titles and descriptions', () => { - it('should return the correct title for v3 pools', () => { - expect(getPositionPageTitle('/pools')).toBe('Manage pool liquidity on Uniswap') - }) - - it('should return the correct title for v2 pools', () => { - expect(getPositionPageTitle('/pools/v2')).toBe('Manage pool liquidity (v2) on Uniswap') + it('should return the correct title & description for v4 positions page', () => { + const v4PositionsPageUrl = '/positions/v4/optimism/512372' + expect(getPositionPageTitle(v4PositionsPageUrl)).toBe('Manage pool liquidity on Uniswap') + expect(getPositionPageDescription(v4PositionsPageUrl)).toBe( + 'View your active v4 liquidity positions. Add new positions.', + ) }) - it('should return the correct description for v3 pools', () => { - expect(getPositionPageDescription('/pool/512372?chain=optimism')).toBe( + it('should return the correct title & description for v3 positions page', () => { + const v3PositionsPageUrl = '/positions/v3/optimism/512372' + expect(getPositionPageTitle(v3PositionsPageUrl)).toBe('Manage pool liquidity (v3) on Uniswap') + expect(getPositionPageDescription(v3PositionsPageUrl)).toBe( 'View your active v3 liquidity positions. Add new positions.', ) }) - it('should return the correct description for v2 pools', () => { - expect(getPositionPageDescription('/pool/v2')).toBe('View your active v2 liquidity positions. Add new positions.') + it('should return the correct title & description for v2 positions page', () => { + const v2PositionsPageUrl = '/positions/v2/ethereum/0x004375Dff511095CC5A197A54140a24eFEF3A416' + expect(getPositionPageTitle(v2PositionsPageUrl)).toBe('Manage pool liquidity (v2) on Uniswap') + expect(getPositionPageDescription(v2PositionsPageUrl)).toBe( + 'View your active v2 liquidity positions. Add new positions.', + ) }) it('should return the correct title for Add Liquidity pages', () => { diff --git a/apps/web/src/pages/paths.ts b/apps/web/src/pages/paths.ts index ce7c8ae2704..0440763d3e4 100644 --- a/apps/web/src/pages/paths.ts +++ b/apps/web/src/pages/paths.ts @@ -42,7 +42,7 @@ export const paths = [ '/remove/:tokenId', '/migrate/v2', '/migrate/v2/:address', - '/migrate/v3/:tokenId', + '/migrate/v3/:chainName/:tokenId', '/nfts', '/nfts/asset/:contractAddress/:tokenId', '/nfts/profile', diff --git a/apps/web/src/state/explore/topTokens.ts b/apps/web/src/state/explore/topTokens.ts index 3e09a0d4ee5..7da2a00ac1d 100644 --- a/apps/web/src/state/explore/topTokens.ts +++ b/apps/web/src/state/explore/topTokens.ts @@ -9,7 +9,7 @@ import { } from 'components/Tokens/state' import { getChainFromChainUrlParam } from 'constants/chains' import { NATIVE_CHAIN_ID } from 'constants/tokens' -import { SparklineMap } from 'graphql/data/TopTokens' +import { SparklineMap } from 'graphql/data/types' import { PricePoint, TimePeriod, unwrapToken } from 'graphql/data/util' import { useAtomValue } from 'jotai/utils' import { useContext, useMemo } from 'react' diff --git a/apps/web/src/state/explore/types.ts b/apps/web/src/state/explore/types.ts index 42166933e31..65effb5e3ec 100644 --- a/apps/web/src/state/explore/types.ts +++ b/apps/web/src/state/explore/types.ts @@ -1,6 +1,7 @@ // eslint-disable-next-line no-restricted-imports import { Amount, PoolStats, TokenStats } from '@uniswap/client-explore/dist/uniswap/explore/v1/service_pb' import { Percent } from '@uniswap/sdk-core' +import { FeeData } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' type PricePoint = { timestamp: number; value: number } @@ -8,6 +9,7 @@ export interface TokenStat extends Omit { volume?: Amount priceHistory?: PricePoint[] + feeData?: FeeData } type PoolStatWithoutMethods = Omit< diff --git a/apps/web/src/state/mint/v3/utils.ts b/apps/web/src/state/mint/v3/utils.ts index 92bb994f089..0b1de720582 100644 --- a/apps/web/src/state/mint/v3/utils.ts +++ b/apps/web/src/state/mint/v3/utils.ts @@ -1,4 +1,4 @@ -import { Price, Token } from '@uniswap/sdk-core' +import { Currency, Price, Token } from '@uniswap/sdk-core' import { FeeAmount, TICK_SPACINGS, @@ -9,7 +9,7 @@ import { } from '@uniswap/v3-sdk' import JSBI from 'jsbi' -export function tryParsePrice(baseToken?: Token, quoteToken?: Token, value?: string) { +export function tryParsePrice(baseToken?: T, quoteToken?: T, value?: string) { if (!baseToken || !quoteToken || !value) { return undefined } diff --git a/apps/web/src/state/sagas/liquidity/liquiditySaga.ts b/apps/web/src/state/sagas/liquidity/liquiditySaga.ts index fd5f9fb7cca..1e243144844 100644 --- a/apps/web/src/state/sagas/liquidity/liquiditySaga.ts +++ b/apps/web/src/state/sagas/liquidity/liquiditySaga.ts @@ -5,7 +5,6 @@ import { handleOnChainStep, handleSignatureStep, } from 'state/sagas/transactions/utils' -import { handleWrapStep } from 'state/sagas/transactions/wrapSaga' import { DecreaseLiquidityTransactionInfo, IncreaseLiquidityTransactionInfo, @@ -19,9 +18,10 @@ import { DecreasePositionTransactionStep, IncreasePositionTransactionStep, IncreasePositionTransactionStepAsync, + MigratePositionTransactionStep, + MigratePositionTransactionStepAsync, TransactionStep, TransactionStepType, - WrapTransactionStep, } from 'uniswap/src/features/transactions/swap/types/steps' import { SetCurrentStepFn } from 'uniswap/src/features/transactions/swap/types/swapCallback' import { generateTransactionSteps } from 'uniswap/src/features/transactions/swap/utils/generateTransactionSteps' @@ -45,13 +45,14 @@ function* getLiquidityTxRequest( | IncreasePositionTransactionStep | IncreasePositionTransactionStepAsync | DecreasePositionTransactionStep - | WrapTransactionStep, + | MigratePositionTransactionStep + | MigratePositionTransactionStepAsync, signature: string | undefined, ) { if ( step.type === TransactionStepType.IncreasePositionTransaction || step.type === TransactionStepType.DecreasePositionTransaction || - step.type === TransactionStepType.WrapTransaction + step.type === TransactionStepType.MigratePositionTransactionStep ) { return step.txRequest } @@ -75,7 +76,8 @@ interface HandlePositionStepParams extends Omit void + onModification?: (response: TransactionResponse) => void | Generator } export function* handleOnChainStep(params: HandleOnChainStepParams) { const { account, step, setCurrentStep, info, allowDuplicativeTx, ignoreInterrupt, onModification } = params @@ -126,8 +129,8 @@ export function* handleOnChainStep(params: Han // Add transaction to local state to start polling for status yield* put(addTransaction({ from: account.address, info, hash, nonce, chainId })) - if (step.txRequest.data !== data) { - onModification?.(response) + if (step.txRequest.data !== data && onModification) { + yield* call(onModification, response) } // If the transaction flow was interrupted while awaiting input, throw an error after input is received @@ -168,7 +171,26 @@ interface HandleApprovalStepParams export function* handleApprovalTransactionStep(params: HandleApprovalStepParams) { const { step } = params const info = getApprovalTransactionInfo(step) - return yield* call(handleOnChainStep, { ...params, info }) + return yield* call(handleOnChainStep, { + ...params, + info, + *onModification(response: TransactionResponse) { + const { isInsufficient, approvedAmount } = checkApprovalAmount(response, step) + + // Update state to reflect hte actual approval amount submitted on-chain + yield* put( + updateTransactionInfo({ + chainId: step.txRequest.chainId, + hash: response.hash, + info: { ...info, amount: approvedAmount }, + }), + ) + + if (isInsufficient) { + throw new ApprovalEditedInWalletError({ step }) + } + }, + }) } function getApprovalTransactionInfo( @@ -182,6 +204,22 @@ function getApprovalTransactionInfo( } } +function checkApprovalAmount( + response: TransactionResponse, + step: TokenApprovalTransactionStep | TokenRevocationTransactionStep, +) { + const requiredAmount = BigInt(`0x${parseInt(step.amount, 10).toString(16)}`) + const submitted = parseERC20ApproveCalldata(response.data) + const approvedAmount = submitted.amount.toString(10) + + // Special case: for revoke tx's, the approval is insufficient if anything other than an empty approval was submitted on chain. + if (step.type === TransactionStepType.TokenRevocationTransaction) { + return { isInsufficient: submitted.amount !== BigInt(0), approvedAmount } + } + + return { isInsufficient: submitted.amount < requiredAmount, approvedAmount } +} + function isRecentTx(tx: TransactionDetails) { const currentTime = Date.now() const failed = tx.status === TransactionStatus.Failed diff --git a/apps/web/src/state/transactions/reducer.ts b/apps/web/src/state/transactions/reducer.ts index fb500857dac..c94dc3adbd3 100644 --- a/apps/web/src/state/transactions/reducer.ts +++ b/apps/web/src/state/transactions/reducer.ts @@ -112,6 +112,25 @@ const localTransactionSlice = createSlice({ } tx.info.depositConfirmed = true }, + updateTransactionInfo( + transactions, + { + payload: { chainId, hash, info }, + }: { + payload: { + chainId: UniverseChainId + hash: string + info: TransactionInfo + } + }, + ) { + const tx = transactions[chainId]?.[hash] + if (!tx || tx.info.type !== info.type) { + return + } + + tx.info = info + }, cancelTransaction( transactions, { @@ -134,6 +153,7 @@ const localTransactionSlice = createSlice({ export const { addTransaction, + updateTransactionInfo, clearAllTransactions, checkedTransaction, finalizeTransaction, diff --git a/apps/web/src/test-utils/bundle-size-test.ts b/apps/web/src/test-utils/bundle-size-test.ts index 107f19d3d5c..0c21160709d 100644 --- a/apps/web/src/test-utils/bundle-size-test.ts +++ b/apps/web/src/test-utils/bundle-size-test.ts @@ -32,8 +32,8 @@ const entryGzipSize = report.reduce( 0, ) -// somewhat arbitrary, based on current size (10/4/2024) -const limit = 2_300_000 +// somewhat arbitrary, based on current size (10/22/2024) +const limit = 2_337_000 if (entryGzipSize > limit) { console.error(`Bundle size has grown too big! Entry JS size is ${entryGzipSize}, over the limit of ${limit}.`) diff --git a/apps/web/src/test-utils/pools/fixtures.ts b/apps/web/src/test-utils/pools/fixtures.ts index 2eeee1a0a5d..5297dffa69c 100644 --- a/apps/web/src/test-utils/pools/fixtures.ts +++ b/apps/web/src/test-utils/pools/fixtures.ts @@ -2,13 +2,14 @@ import { BigNumber } from '@ethersproject/bignumber' import { Currency, WETH9 } from '@uniswap/sdk-core' import { FeeAmount, Pool, Position } from '@uniswap/v3-sdk' import { PoolData } from 'graphql/data/pools/usePoolData' +import { PoolStat } from 'state/explore/types' import { USDC_MAINNET } from 'uniswap/src/constants/tokens' import { Token } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' import { UniverseChainId } from 'uniswap/src/types/chains' export const validParams = { poolAddress: '0x88e6a0c2ddd26feeb64f039a2c41296fcb3f5640', chainName: 'ethereum' } -export const validBEPoolToken0 = { +const validPoolToken0 = { id: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', symbol: 'USDC', @@ -25,7 +26,10 @@ export const validBEPoolToken0 = { url: 'https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', }, }, -} as Token +} + +export const validBEPoolToken0 = validPoolToken0 as Token +export const validRestPoolToken0 = validPoolToken0 as unknown as PoolStat['token0'] export const validUSDCCurrency = { isNative: false, @@ -42,7 +46,7 @@ export const validUSDCCurrency = { wrapped: validBEPoolToken0, } as unknown as Currency -export const validBEPoolToken1 = { +const validPoolToken1 = { symbol: 'WETH', name: 'Wrapped Ether', derivedETH: '1', @@ -59,7 +63,10 @@ export const validBEPoolToken1 = { url: 'https://raw.githubusercontent.com/Uniswap/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', }, }, -} as Token +} + +export const validBEPoolToken1 = validPoolToken1 as Token +export const validRestPoolToken1 = validPoolToken1 as unknown as PoolStat['token0'] export const owner = '0xf5b6bb25f5beaea03dd014c6ef9fa9f3926bf36c' diff --git a/apps/web/src/utils/chains.tsx b/apps/web/src/utils/chains.tsx index a6db9e43d37..cea6a2909e1 100644 --- a/apps/web/src/utils/chains.tsx +++ b/apps/web/src/utils/chains.tsx @@ -1,5 +1,6 @@ import { CHAIN_IDS_TO_NAMES } from 'constants/chains' import { ParsedQs } from 'qs' +import { UNIVERSE_CHAIN_INFO } from 'uniswap/src/constants/chains' function getChainIdFromName(name: string) { const entry = Object.entries(CHAIN_IDS_TO_NAMES).find(([, n]) => n === name) @@ -7,6 +8,16 @@ function getChainIdFromName(name: string) { return chainId ? parseInt(chainId) : undefined } +// i.e. ?chain=mainnet -> ethereum +export function searchParamToBackendName(interfaceName: string | null): string | undefined { + if (interfaceName === null) { + return undefined + } + + const chain = Object.values(UNIVERSE_CHAIN_INFO).find((item) => item.interfaceName === interfaceName) + return chain ? chain.urlParam : undefined +} + export enum ParsedChainIdKey { INPUT = 'input', OUTPUT = 'output', diff --git a/apps/web/src/utils/currencyId.ts b/apps/web/src/utils/currencyId.ts index 34ac1386b03..1088c452466 100644 --- a/apps/web/src/utils/currencyId.ts +++ b/apps/web/src/utils/currencyId.ts @@ -1,5 +1,6 @@ import { Currency } from '@uniswap/sdk-core' +/** @deprecated confusing since currencyId from packages/uniswap is formatted as `chainId-address` */ export function currencyId(currency?: Currency): string { if (currency?.isNative) { return 'ETH' diff --git a/apps/web/src/utils/getTickToPrice.ts b/apps/web/src/utils/getTickToPrice.ts index 79973460fa8..9fb7b7f0b69 100644 --- a/apps/web/src/utils/getTickToPrice.ts +++ b/apps/web/src/utils/getTickToPrice.ts @@ -1,5 +1,6 @@ -import { Price, Token } from '@uniswap/sdk-core' +import { Currency, Price, Token } from '@uniswap/sdk-core' import { tickToPrice } from '@uniswap/v3-sdk' +import { tickToPrice as tickToPriceV4 } from '@uniswap/v4-sdk' export function getTickToPrice(baseToken?: Token, quoteToken?: Token, tick?: number): Price | undefined { if (!baseToken || !quoteToken || typeof tick !== 'number') { @@ -7,3 +8,14 @@ export function getTickToPrice(baseToken?: Token, quoteToken?: Token, tick?: num } return tickToPrice(baseToken, quoteToken, tick) } + +export function getV4TickToPrice( + baseCurrency?: Currency, + quoteCurrency?: Currency, + tick?: number, +): Price | undefined { + if (!baseCurrency || !quoteCurrency || typeof tick !== 'number') { + return undefined + } + return tickToPriceV4(baseCurrency, quoteCurrency, tick) +} diff --git a/apps/web/src/utils/splitHiddenTokens.test.tsx b/apps/web/src/utils/splitHiddenTokens.test.tsx index da75b167843..a237509b989 100644 --- a/apps/web/src/utils/splitHiddenTokens.test.tsx +++ b/apps/web/src/utils/splitHiddenTokens.test.tsx @@ -54,9 +54,9 @@ const tokens: TokenBalance[] = [ }, }, }, - // spam + // spam (boolean) { - id: 'spam', + id: 'spam-boolean', ownerAddress: '', __typename: 'TokenBalance', denominatedValue: { @@ -69,6 +69,43 @@ const tokens: TokenBalance[] = [ id: '', tokens: [nonnativeToken], isSpam: true, + spamCode: 1, + }, + }, + }, + { + id: 'spam-code', + ownerAddress: '', + __typename: 'TokenBalance', + denominatedValue: { + id: '', + value: 100, + }, + token: { + ...nonnativeToken, + project: { + id: '', + tokens: [nonnativeToken], + isSpam: false, + spamCode: 2, + }, + }, + }, + { + id: 'spam-both', + ownerAddress: '', + __typename: 'TokenBalance', + denominatedValue: { + id: '', + value: 100, + }, + token: { + ...nonnativeToken, + project: { + id: '', + tokens: [nonnativeToken], + isSpam: true, + spamCode: 2, }, }, }, @@ -112,51 +149,79 @@ const tokens: TokenBalance[] = [ ] describe('splitHiddenTokens', () => { - it('splits spam tokens into hidden but keeps small balances if hideSmallBalances = false', () => { - const { visibleTokens, hiddenTokens } = splitHiddenTokens(tokens, { hideSmallBalances: false }) + it('[prod mode] splits spam tokens into hidden but keeps small balances if hideSmallBalances = false', () => { + const { visibleTokens, hiddenTokens } = splitHiddenTokens(tokens, { + hideSmallBalances: false, + isTestnetModeEnabled: false, + }) - expect(hiddenTokens.length).toBe(1) - expect(hiddenTokens[0].id).toBe('spam') + expect(hiddenTokens.length).toBe(2) + expect(hiddenTokens[0].id).toBe('spam-boolean') + expect(hiddenTokens[1].id).toBe('spam-both') - expect(visibleTokens.length).toBe(4) + expect(visibleTokens.length).toBe(5) expect(visibleTokens[0].id).toBe('low-balance-native') expect(visibleTokens[1].id).toBe('low-balance-nonnative') - expect(visibleTokens[2].id).toBe('valid') - expect(visibleTokens[3].id).toBe('undefined-value') + expect(visibleTokens[2].id).toBe('spam-code') + expect(visibleTokens[3].id).toBe('valid') + expect(visibleTokens[4].id).toBe('undefined-value') + }) + + it('[testnet mode] splits spam tokens into hidden but keeps small balances if hideSmallBalances = false', () => { + const { visibleTokens, hiddenTokens } = splitHiddenTokens(tokens, { + hideSmallBalances: false, + isTestnetModeEnabled: true, + }) + + expect(hiddenTokens.length).toBe(2) + expect(hiddenTokens[0].id).toBe('spam-code') + expect(hiddenTokens[1].id).toBe('spam-both') + + expect(visibleTokens.length).toBe(5) + expect(visibleTokens[0].id).toBe('low-balance-native') + expect(visibleTokens[1].id).toBe('low-balance-nonnative') + expect(visibleTokens[2].id).toBe('spam-boolean') + expect(visibleTokens[3].id).toBe('valid') + expect(visibleTokens[4].id).toBe('undefined-value') }) it('splits small balance tokens into hidden but keeps small balances if hideSpam = false', () => { - const { visibleTokens, hiddenTokens } = splitHiddenTokens(tokens, { hideSpam: false }) + const { visibleTokens, hiddenTokens } = splitHiddenTokens(tokens, { hideSpam: false, isTestnetModeEnabled: false }) expect(hiddenTokens.length).toBe(1) expect(hiddenTokens[0].id).toBe('low-balance-nonnative') - expect(visibleTokens.length).toBe(4) + expect(visibleTokens.length).toBe(6) expect(visibleTokens[0].id).toBe('low-balance-native') - expect(visibleTokens[1].id).toBe('spam') - expect(visibleTokens[2].id).toBe('valid') - expect(visibleTokens[3].id).toBe('undefined-value') + expect(visibleTokens[1].id).toBe('spam-boolean') + expect(visibleTokens[2].id).toBe('spam-code') + expect(visibleTokens[3].id).toBe('spam-both') + expect(visibleTokens[4].id).toBe('valid') + expect(visibleTokens[5].id).toBe('undefined-value') }) it('splits non-native low balance into hidden by default', () => { - const { visibleTokens, hiddenTokens } = splitHiddenTokens(tokens) + const { visibleTokens, hiddenTokens } = splitHiddenTokens(tokens, { isTestnetModeEnabled: false }) - expect(hiddenTokens.length).toBe(2) + expect(hiddenTokens.length).toBe(3) expect(hiddenTokens[0].id).toBe('low-balance-nonnative') - expect(hiddenTokens[1].id).toBe('spam') + expect(hiddenTokens[1].id).toBe('spam-boolean') + expect(hiddenTokens[2].id).toBe('spam-both') - expect(visibleTokens.length).toBe(3) + expect(visibleTokens.length).toBe(4) expect(visibleTokens[0].id).toBe('low-balance-native') - expect(visibleTokens[1].id).toBe('valid') - expect(visibleTokens[2].id).toBe('undefined-value') + expect(visibleTokens[1].id).toBe('spam-code') + expect(visibleTokens[2].id).toBe('valid') + expect(visibleTokens[3].id).toBe('undefined-value') }) it('splits undefined value tokens into visible', () => { - const { visibleTokens } = splitHiddenTokens(tokens) + const { visibleTokens } = splitHiddenTokens(tokens, { isTestnetModeEnabled: false }) - expect(visibleTokens.length).toBe(3) + expect(visibleTokens.length).toBe(4) expect(visibleTokens[0].id).toBe('low-balance-native') - expect(visibleTokens[1].id).toBe('valid') - expect(visibleTokens[2].id).toBe('undefined-value') + expect(visibleTokens[1].id).toBe('spam-code') + expect(visibleTokens[2].id).toBe('valid') + expect(visibleTokens[3].id).toBe('undefined-value') }) }) diff --git a/apps/web/src/utils/splitHiddenTokens.tsx b/apps/web/src/utils/splitHiddenTokens.tsx index 6492cadb39a..e8683348603 100644 --- a/apps/web/src/utils/splitHiddenTokens.tsx +++ b/apps/web/src/utils/splitHiddenTokens.tsx @@ -1,16 +1,18 @@ import { PortfolioBalance } from 'graphql/data/portfolios' import { TokenStandard } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' +import { SpamCode } from 'uniswap/src/data/types' const HIDE_SMALL_USD_BALANCES_THRESHOLD = 1 export interface SplitOptions { + isTestnetModeEnabled: boolean hideSmallBalances?: boolean hideSpam?: boolean } export function splitHiddenTokens( tokenBalances: readonly (PortfolioBalance | undefined)[], - { hideSmallBalances = true, hideSpam = true }: SplitOptions = {}, + { isTestnetModeEnabled, hideSmallBalances = true, hideSpam = true }: SplitOptions, ) { const visibleTokens: PortfolioBalance[] = [] const hiddenTokens: PortfolioBalance[] = [] @@ -20,7 +22,9 @@ export function splitHiddenTokens( continue } - const isSpam = tokenBalance.token?.project?.isSpam + const isSpam = isTestnetModeEnabled + ? (tokenBalance.token?.project?.spamCode || SpamCode.LOW) >= SpamCode.HIGH + : tokenBalance.token?.project?.isSpam if ((hideSpam && isSpam) || (hideSmallBalances && isNegligibleBalance(tokenBalance))) { hiddenTokens.push(tokenBalance) } else { diff --git a/package.json b/package.json index 707d79a7829..f04f3bcdd43 100644 --- a/package.json +++ b/package.json @@ -44,10 +44,10 @@ "cypress": "13.7.3", "@babel/preset-env": "7.23.3", "immer": "9.0.21", - "@uniswap/v2-sdk": "4.6.0", - "@uniswap/router-sdk": "1.14.2", + "@uniswap/v2-sdk": "4.6.1", + "@uniswap/router-sdk": "1.14.3", "@apollo/client": "3.10.4", - "@uniswap/sdk-core": "5.8.4", + "@uniswap/sdk-core": "5.9.0", "@react-navigation/routers": "6.1.9", "@react-navigation/core": "6.2.2", "@sideway/formula": "3.0.1", diff --git a/packages/eslint-config/__snapshots__/preset.test.ts.snap b/packages/eslint-config/__snapshots__/preset.test.ts.snap index cc5026bf32c..3002605fd37 100644 --- a/packages/eslint-config/__snapshots__/preset.test.ts.snap +++ b/packages/eslint-config/__snapshots__/preset.test.ts.snap @@ -891,6 +891,17 @@ exports[`should have a correct configuration for a React file 1`] = ` "message": "Use \`useAppInsets\` instead.", "name": "ui/src/hooks/useDeviceInsets", }, + { + "importNames": [ + "getUniqueId", + ], + "message": "Not supported for web/extension, use \`getUniqueId\` from \`utilities/src/device/getUniqueId\` instead.", + "name": "react-native-device-info", + }, + { + "message": "Use specific imports (e.g. \`import isEqual from 'lodash/isEqual'\`) to avoid pulling in all of lodash to web to keep bundle size down!", + "name": "lodash", + }, { "message": "Please import from '@ethersproject/module' directly to support tree-shaking.", "name": "ethers", diff --git a/packages/eslint-config/native.js b/packages/eslint-config/native.js index 9fa81917c5c..1ede3402523 100644 --- a/packages/eslint-config/native.js +++ b/packages/eslint-config/native.js @@ -312,7 +312,7 @@ module.exports = { }, // enforce saga imports from typed-redux-saga { - files: ['./**/*.ts'], + files: ['./**/*.ts', './**/*.tsx'], excludedFiles: ['./**/*.test.ts', './**/*.test.tsx'], rules: { '@jambit/typed-redux-saga/use-typed-effects': 'error', diff --git a/packages/eslint-config/restrictedImports.js b/packages/eslint-config/restrictedImports.js index c79f22cf8c7..f20e410a43b 100644 --- a/packages/eslint-config/restrictedImports.js +++ b/packages/eslint-config/restrictedImports.js @@ -43,6 +43,15 @@ exports.shared = { importNames: ['useDeviceInsets'], message: 'Use `useAppInsets` instead.' }, + { + name: 'react-native-device-info', + importNames: ['getUniqueId'], + message: 'Not supported for web/extension, use `getUniqueId` from `utilities/src/device/getUniqueId` instead.' + }, + { + name: 'lodash', + message: 'Use specific imports (e.g. `import isEqual from \'lodash/isEqual\'`) to avoid pulling in all of lodash to web to keep bundle size down!', + } ], patterns: [ { diff --git a/packages/ui/README.md b/packages/ui/README.md index fcb905b9438..0ef0b56aee9 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -1,30 +1,21 @@ -# Uniswap UI +# `ui` Package -This package holds a component library and themes that can be used across both mobile and web contexts. Below is instructions for both use and development of this package. +This package holds a component library and themes that can be used across all apps. -## Library Usage +## UI Package Philosophy -### Core components +The `ui` package contains all low level components that are shared between apps. It should *not* contain components that are specific to any one app or Uniswap business logic. Each component should be guided by the following principles: -Many base components are available in the UI library. Below key use cases are detailed, but many more are available. +- All components should be compatible with all platforms. +- Wrap as many implementation details as possible, including any direct exports from Tamagui. +- Export only what’s needed from `ui/src` or another allowlisted path. +- Only include components that will be used beyond a single feature. -While some are customized `tamagui` elements or even fully custom elements, many are simple or direct exposure of `tamagui` elements. If you would like to use any `tamagui` elements, please add them through this library to ensure a layer of abstraction between tamagui and our usage when possible. +Components that are shared between all applications but encode Uniswap business logic should most likely be placed in the `uniswap` package! -#### Flex +## Icons and Logos -The `Flex` component is the core organizational element of the UI library, acting as a base of a flexbox styling approach. Shortcuts are available for `row`, `grow`, `shrink`, `fill`, and `centered`. All other styling props can be added directly as needed. - -#### Text - -The `Text` element is the core element for displaying text through the app. The `variant` prop takes in the text variant from our design system (e.g `heading1`, `body2`, etc.) that is desired. All other text styling props can be added directly as needed. - -#### Button - -The `Button` element is used consistently to ensure action buttons maintain similar action and style, utilizing `size` and `theme` properties to enforce consistent usage. Even when not using either, this component ensures consistent haptics and other implementation details. - -#### Icons and Logos - -Icons and Logos are made available off `ui/src/components/icons` and `ui/src/components/logos`. These files are generated from placing the file in `packages/ui/src/assets/icons` or `packages/ui/src/assets/logos/svg` and running the generate command(s): +Icons and logos are placed in `ui/src/components/{icons|logos}`. These files are generated from placing the file in `packages/ui/src/assets/icons` or `packages/ui/src/assets/logos/svg` and running the generate command(s): ```bash # Generate all icons @@ -37,6 +28,22 @@ When adding an SVG, please ensure you replace color references as needed with `c Custom icons that take props can be added to the same icons import by adding the file in `packages/ui/src/components/icons/index.ts`. +## Core Components + +Many base components are available in the UI library. While some are customized `tamagui` elements or even fully custom elements, many are simple or direct exposure of `tamagui` elements. If you would like to use any `tamagui` elements, please add them through this library to ensure a layer of abstraction between tamagui and our usage when possible. + +Below are summaries of the most commonly used elements. + +### Flex + +The `Flex` component is the core organizational element of the UI library, acting as a base of a flexbox styling approach. Shortcuts are available for `row`, `grow`, `shrink`, `fill`, and `centered`. All other styling props can be added directly as needed. + +### Text + +The `Text` element is the core element for displaying text through the app. The `variant` prop takes in the text variant from our design system (e.g `heading1`, `body2`, etc.) that is desired. All other text styling props can be added directly as needed. + +## Theme and Platform + ### Theming Theming is applied through two primary methods: @@ -69,9 +76,7 @@ To account for screen size references, components take in props to adapt custom When components cannot take in these props or a value needs to be resued multiple times, the `useMedia` hook allows these same breakpoint values to be defined programmatically. -### Other notable usage - -#### Accessing colors +### Colors We've made a hook `useSporeColors()` which gives you access to the current theme, and you can access the values off it as follows: @@ -86,7 +91,7 @@ function MyComponent() { } ``` -##### When to use `.get()` vs `.val` +#### When to use `.get()` vs `.val` After some discussion we've come to prefer `.val` by default. This will always return the raw string color (in our case hex color) on all platforms, whereas `.get()` returns either an Object (iOS) or a string, which can cause issues when used with things like animations, or external components. @@ -102,65 +107,3 @@ In summary: - On iOS, it returns an object like `{ dynamic: { light: '#fff', dark: '#000' } }` - On the web, it returns a CSS variable string, `var(--colorName)` - You can call `.get('web')` to only optimize for web, or `.get('ios')` to only optimize for ios. - -#### When should you use `styled()` vs inline props - -When possible, usage of `styled` should be limited to use within the `ui` package or direct styling only repetition of . In general try to use the following rules: - -1. If it's used only once, always prefer inline for simplicity's sake. It avoids having to name things and avoids having to jump between files or places in code to understand styling. - -2. If it's used more than once, try to create a component that abstracts properties to the minimum needed. This new component should live as close to the usage as possible, so if used only within a single file, keep it within that file. You should only use `styled` when said component is being used more than once, otherwise defer to inline styles. - -3. If it's used across multiple apps or has a strong potential to be, consider extracting it into a shared package. To decide where to place it, see "What code should be placed in the package?" - -## Library Design - -### What code should be placed in the package? - -The `ui` package should be where we put all low level interface components that are shared between apps (or will likely be shared). It should *not* contain components that are specific to any one app, or which touch app-level data in any way. - -A good rule of thumb is: if it deals with app-specific state (eg anything thats stored in redux) or has a very complex interface, then: - -- If it's specific to just one app, put it in that app. -- Else if it's shared between multiple apps, put it in a shared package above the UI package, e.g `packages/{package}`. - -Otherwise, if it's simpler, put it in `packages/ui`. - -Think of `packages/ui` as our low level and more pure building blocks for interface, and `packages/{package}` as our higher level shared components that deal with complex dependencies or app-specific state. - -### Optimization - -The Tamagui optimizing compiler will extract CSS and do tree-flattening optimizations for all of `ui`, and for all usages of components from `ui` in the apps. - -Where it will bail out of optimization is if you define a new `styled()` component outside of `ui` and then use that component. - -But this is fine! It will still be pretty fast. - -Also, in the future we can turn `enableDynamicEvaluation` which enables the compiler to optimize those one-off `styled()` definitions within apps to also extract CSS and flatten. Its still a bit of a beta feature so no rush to turn it on, and likely it's fast enough that we don't need to worry about it. - -### useStyle, useProps, and usePropsAndStyle - -These three are useful more advanced patterns to get styles or props from Tamagui form into plain objects and [are documented on the Tamagui site](https://tamagui.dev/docs/core/exports#useprops). - -In short: - -- `useProps`: takes in props returns props as-is just with media queries resolved and shorthands expanded (not transformed so tokens like $color stay as $color) -- `useStyle`: takes in props returns only styles (media queries, shorthands, tokens, etc resolved) -- `usePropsAndStyle`: splits the props and styles apart for you, fully resolves everything - -Also the `forComponent` pattern is useful and works with all of them: - -```tsx -const CustomText = styled(Text, { - variants: { - large: { - true: { - fontSize: 100 - } - } - } -}) - -useStyle({ large: true }, { forComponent: CustomText } }) -// returns { fontSize: 100 } -``` diff --git a/packages/ui/src/assets/backgrounds/android/notifications-dark.png b/packages/ui/src/assets/backgrounds/android/notifications-dark.png index ade6e0f3d92..5ce57622275 100644 Binary files a/packages/ui/src/assets/backgrounds/android/notifications-dark.png and b/packages/ui/src/assets/backgrounds/android/notifications-dark.png differ diff --git a/packages/ui/src/assets/backgrounds/android/notifications-light.png b/packages/ui/src/assets/backgrounds/android/notifications-light.png index 5768534ab4a..19b4c1c1553 100644 Binary files a/packages/ui/src/assets/backgrounds/android/notifications-light.png and b/packages/ui/src/assets/backgrounds/android/notifications-light.png differ diff --git a/packages/ui/src/assets/backgrounds/android/security-background-dark.png b/packages/ui/src/assets/backgrounds/android/security-background-dark.png index 6f97db70dc4..54938d6df4d 100644 Binary files a/packages/ui/src/assets/backgrounds/android/security-background-dark.png and b/packages/ui/src/assets/backgrounds/android/security-background-dark.png differ diff --git a/packages/ui/src/assets/backgrounds/android/security-background-light.png b/packages/ui/src/assets/backgrounds/android/security-background-light.png index 9b224e7607c..0479fcef6f1 100644 Binary files a/packages/ui/src/assets/backgrounds/android/security-background-light.png and b/packages/ui/src/assets/backgrounds/android/security-background-light.png differ diff --git a/packages/ui/src/assets/backgrounds/ios/notifications-dark.png b/packages/ui/src/assets/backgrounds/ios/notifications-dark.png index c1b0f40ff91..3aa12b550a2 100644 Binary files a/packages/ui/src/assets/backgrounds/ios/notifications-dark.png and b/packages/ui/src/assets/backgrounds/ios/notifications-dark.png differ diff --git a/packages/ui/src/assets/backgrounds/ios/notifications-light.png b/packages/ui/src/assets/backgrounds/ios/notifications-light.png index d4b5b7ec347..bc751e18d02 100644 Binary files a/packages/ui/src/assets/backgrounds/ios/notifications-light.png and b/packages/ui/src/assets/backgrounds/ios/notifications-light.png differ diff --git a/packages/ui/src/assets/backgrounds/ios/security-background-dark.png b/packages/ui/src/assets/backgrounds/ios/security-background-dark.png index b297a2c9e57..f384c519588 100644 Binary files a/packages/ui/src/assets/backgrounds/ios/security-background-dark.png and b/packages/ui/src/assets/backgrounds/ios/security-background-dark.png differ diff --git a/packages/ui/src/assets/backgrounds/ios/security-background-light.png b/packages/ui/src/assets/backgrounds/ios/security-background-light.png index c1bf7e5f760..ebc72439f59 100644 Binary files a/packages/ui/src/assets/backgrounds/ios/security-background-light.png and b/packages/ui/src/assets/backgrounds/ios/security-background-light.png differ diff --git a/packages/ui/src/assets/graphics/unitags/adrian-dark.png b/packages/ui/src/assets/graphics/unitags/adrian-dark.png new file mode 100644 index 00000000000..830aa2c08d6 Binary files /dev/null and b/packages/ui/src/assets/graphics/unitags/adrian-dark.png differ diff --git a/packages/ui/src/assets/graphics/unitags/adrian-light.png b/packages/ui/src/assets/graphics/unitags/adrian-light.png new file mode 100644 index 00000000000..78d839d33bb Binary files /dev/null and b/packages/ui/src/assets/graphics/unitags/adrian-light.png differ diff --git a/packages/ui/src/assets/graphics/unitags/andrew-dark.png b/packages/ui/src/assets/graphics/unitags/andrew-dark.png new file mode 100644 index 00000000000..1ed2ffe9750 Binary files /dev/null and b/packages/ui/src/assets/graphics/unitags/andrew-dark.png differ diff --git a/packages/ui/src/assets/graphics/unitags/andrew-light.png b/packages/ui/src/assets/graphics/unitags/andrew-light.png new file mode 100644 index 00000000000..1e285aaa60a Binary files /dev/null and b/packages/ui/src/assets/graphics/unitags/andrew-light.png differ diff --git a/packages/ui/src/assets/graphics/unitags/bryan-dark.png b/packages/ui/src/assets/graphics/unitags/bryan-dark.png new file mode 100644 index 00000000000..20d1578598e Binary files /dev/null and b/packages/ui/src/assets/graphics/unitags/bryan-dark.png differ diff --git a/packages/ui/src/assets/graphics/unitags/bryan-light.png b/packages/ui/src/assets/graphics/unitags/bryan-light.png new file mode 100644 index 00000000000..a735d87c266 Binary files /dev/null and b/packages/ui/src/assets/graphics/unitags/bryan-light.png differ diff --git a/packages/ui/src/assets/graphics/unitags/callil-dark.png b/packages/ui/src/assets/graphics/unitags/callil-dark.png new file mode 100644 index 00000000000..a2f4930015d Binary files /dev/null and b/packages/ui/src/assets/graphics/unitags/callil-dark.png differ diff --git a/packages/ui/src/assets/graphics/unitags/callil-light.png b/packages/ui/src/assets/graphics/unitags/callil-light.png new file mode 100644 index 00000000000..4c6684bc06d Binary files /dev/null and b/packages/ui/src/assets/graphics/unitags/callil-light.png differ diff --git a/packages/ui/src/assets/graphics/unitags/fred-dark.png b/packages/ui/src/assets/graphics/unitags/fred-dark.png new file mode 100644 index 00000000000..3846dfb3a57 Binary files /dev/null and b/packages/ui/src/assets/graphics/unitags/fred-dark.png differ diff --git a/packages/ui/src/assets/graphics/unitags/fred-light.png b/packages/ui/src/assets/graphics/unitags/fred-light.png new file mode 100644 index 00000000000..67311fd3ae9 Binary files /dev/null and b/packages/ui/src/assets/graphics/unitags/fred-light.png differ diff --git a/packages/ui/src/assets/graphics/unitags/maggie-dark.png b/packages/ui/src/assets/graphics/unitags/maggie-dark.png new file mode 100644 index 00000000000..3ad6c571fbb Binary files /dev/null and b/packages/ui/src/assets/graphics/unitags/maggie-dark.png differ diff --git a/packages/ui/src/assets/graphics/unitags/maggie-light.png b/packages/ui/src/assets/graphics/unitags/maggie-light.png new file mode 100644 index 00000000000..a0c3a6a96e6 Binary files /dev/null and b/packages/ui/src/assets/graphics/unitags/maggie-light.png differ diff --git a/packages/ui/src/assets/graphics/unitags/phil-dark.png b/packages/ui/src/assets/graphics/unitags/phil-dark.png new file mode 100644 index 00000000000..635d7ed153e Binary files /dev/null and b/packages/ui/src/assets/graphics/unitags/phil-dark.png differ diff --git a/packages/ui/src/assets/graphics/unitags/phil-light.png b/packages/ui/src/assets/graphics/unitags/phil-light.png new file mode 100644 index 00000000000..f081b4cd872 Binary files /dev/null and b/packages/ui/src/assets/graphics/unitags/phil-light.png differ diff --git a/packages/ui/src/assets/graphics/unitags/spencer-dark.png b/packages/ui/src/assets/graphics/unitags/spencer-dark.png new file mode 100644 index 00000000000..8575783439e Binary files /dev/null and b/packages/ui/src/assets/graphics/unitags/spencer-dark.png differ diff --git a/packages/ui/src/assets/graphics/unitags/spencer-light.png b/packages/ui/src/assets/graphics/unitags/spencer-light.png new file mode 100644 index 00000000000..28e67996aca Binary files /dev/null and b/packages/ui/src/assets/graphics/unitags/spencer-light.png differ diff --git a/packages/ui/src/assets/icons/reverse-arrows.svg b/packages/ui/src/assets/icons/reverse-arrows.svg deleted file mode 100644 index 758ee0863aa..00000000000 --- a/packages/ui/src/assets/icons/reverse-arrows.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/packages/ui/src/assets/icons/rotate-left.svg b/packages/ui/src/assets/icons/rotate-left.svg index 25bf03a8192..782e5b7b592 100644 --- a/packages/ui/src/assets/icons/rotate-left.svg +++ b/packages/ui/src/assets/icons/rotate-left.svg @@ -1,3 +1,3 @@ - + diff --git a/packages/ui/src/assets/icons/swap-action-button.svg b/packages/ui/src/assets/icons/swap-action-button.svg deleted file mode 100644 index b0d22132d77..00000000000 --- a/packages/ui/src/assets/icons/swap-action-button.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/packages/ui/src/assets/index.ts b/packages/ui/src/assets/index.ts index 33714cff4b0..c4cbe202ac5 100644 --- a/packages/ui/src/assets/index.ts +++ b/packages/ui/src/assets/index.ts @@ -72,3 +72,20 @@ export const SECURITY_SCREEN_BACKGROUND_LIGHT = { } export const DEAD_LUNI = require('./graphics/dead-luni.png') + +export const UNITAGS_ADRIAN_LIGHT = require('./graphics/unitags/adrian-light.png') +export const UNITAGS_ADRIAN_DARK = require('./graphics/unitags/adrian-dark.png') +export const UNITAGS_ANDREW_LIGHT = require('./graphics/unitags/andrew-light.png') +export const UNITAGS_ANDREW_DARK = require('./graphics/unitags/andrew-dark.png') +export const UNITAGS_BRYAN_LIGHT = require('./graphics/unitags/bryan-light.png') +export const UNITAGS_BRYAN_DARK = require('./graphics/unitags/bryan-dark.png') +export const UNITAGS_CALLIL_LIGHT = require('./graphics/unitags/callil-light.png') +export const UNITAGS_CALLIL_DARK = require('./graphics/unitags/callil-dark.png') +export const UNITAGS_FRED_LIGHT = require('./graphics/unitags/fred-light.png') +export const UNITAGS_FRED_DARK = require('./graphics/unitags/fred-dark.png') +export const UNITAGS_MAGGIE_LIGHT = require('./graphics/unitags/maggie-light.png') +export const UNITAGS_MAGGIE_DARK = require('./graphics/unitags/maggie-dark.png') +export const UNITAGS_PHIL_LIGHT = require('./graphics/unitags/phil-light.png') +export const UNITAGS_PHIL_DARK = require('./graphics/unitags/phil-dark.png') +export const UNITAGS_SPENCER_LIGHT = require('./graphics/unitags/spencer-light.png') +export const UNITAGS_SPENCER_DARK = require('./graphics/unitags/spencer-dark.png') diff --git a/packages/ui/src/components/InlineCard/InlineCard.tsx b/packages/ui/src/components/InlineCard/InlineCard.tsx index 50975471ce5..78994bbc2e1 100644 --- a/packages/ui/src/components/InlineCard/InlineCard.tsx +++ b/packages/ui/src/components/InlineCard/InlineCard.tsx @@ -10,7 +10,7 @@ type InlineCardProps = { color: ColorTokens description: string | JSX.Element iconBackgroundColor?: ColorTokens - heading?: string + heading?: string | JSX.Element CtaButtonIcon?: GeneratedIcon | ((props: IconProps) => JSX.Element) onPressCtaButton?: () => void } @@ -43,11 +43,14 @@ export function InlineCard({ description ) - const headingElement = heading ? ( - - {heading} - - ) : null + const headingElement = + typeof heading === 'string' ? ( + + {heading} + + ) : ( + heading + ) return ( diff --git a/packages/ui/src/components/icons/ReverseArrows.tsx b/packages/ui/src/components/icons/ReverseArrows.tsx deleted file mode 100644 index 18b5ed6bb37..00000000000 --- a/packages/ui/src/components/icons/ReverseArrows.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { Path, Svg } from 'react-native-svg' - -// eslint-disable-next-line no-relative-import-paths/no-relative-import-paths -import { createIcon } from '../factories/createIcon' - -export const [ReverseArrows, AnimatedReverseArrows] = createIcon({ - name: 'ReverseArrows', - getIcon: (props) => ( - - - - ), -}) diff --git a/packages/ui/src/components/icons/RotateLeft.tsx b/packages/ui/src/components/icons/RotateLeft.tsx index 4b224d011e3..fde75606f1b 100644 --- a/packages/ui/src/components/icons/RotateLeft.tsx +++ b/packages/ui/src/components/icons/RotateLeft.tsx @@ -9,7 +9,7 @@ export const [RotateLeft, AnimatedRotateLeft] = createIcon({ ), diff --git a/packages/ui/src/components/icons/SwapActionButton.tsx b/packages/ui/src/components/icons/SwapActionButton.tsx deleted file mode 100644 index 44c10a1efcf..00000000000 --- a/packages/ui/src/components/icons/SwapActionButton.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { ClipPath, Defs, G, Path, Rect, Svg } from 'react-native-svg' - -// eslint-disable-next-line no-relative-import-paths/no-relative-import-paths -import { createIcon } from '../factories/createIcon' - -export const [SwapActionButton, AnimatedSwapActionButton] = createIcon({ - name: 'SwapActionButton', - getIcon: (props) => ( - - - - - - - - - - - ), -}) diff --git a/packages/ui/src/components/icons/exported.ts b/packages/ui/src/components/icons/exported.ts index 9d5f0f25611..c5bd1c54434 100644 --- a/packages/ui/src/components/icons/exported.ts +++ b/packages/ui/src/components/icons/exported.ts @@ -161,7 +161,6 @@ export * from './Receive' export * from './ReceiveAlt' export * from './ReceiveArrow' export * from './ReceiveDots' -export * from './ReverseArrows' export * from './RightArrow' export * from './RotateLeft' export * from './Scan' @@ -194,7 +193,6 @@ export * from './StickyNoteSquare' export * from './StickyNoteTextSquare' export * from './Sun' export * from './Swap' -export * from './SwapActionButton' export * from './SwapArrow' export * from './SwirlyArrowDown' export * from './Testnets' diff --git a/packages/ui/src/components/modal/AdaptiveWebModal.tsx b/packages/ui/src/components/modal/AdaptiveWebModal.tsx index 17d9f4addf8..2e828dac4a1 100644 --- a/packages/ui/src/components/modal/AdaptiveWebModal.tsx +++ b/packages/ui/src/components/modal/AdaptiveWebModal.tsx @@ -1,8 +1,9 @@ import { RemoveScroll } from '@tamagui/remove-scroll' -import { PropsWithChildren, useCallback, useState } from 'react' +import { PropsWithChildren, ReactNode, useCallback, useState } from 'react' import { Adapt, Dialog, GetProps, Sheet, View, VisuallyHidden, styled, useIsTouchDevice } from 'tamagui' import { Flex } from 'ui/src/components/layout' import { zIndices } from 'ui/src/theme' +import { useShadowPropsShort } from 'ui/src/theme/shadows' export function WebBottomSheet({ isOpen, onClose, children, ...rest }: ModalProps): JSX.Element { const isTouchDevice = useIsTouchDevice() @@ -164,3 +165,89 @@ export function AdaptiveWebModal({ ) } + +/** + * Copy of AdaptiveWebModal with a bottom attachment, used temporarily until we can fully test and adapt to rest of app + * TODO WALL-5146 Combine this with AdaptiveWebModal and fix for all use cases + */ +export function WebModalWithBottomAttachment({ + isOpen, + onClose, + children, + adaptToSheet = true, + style, + alignment = 'center', + bottomAttachment, + backgroundColor = '$surface1', + ...rest +}: ModalProps & { bottomAttachment?: ReactNode }): JSX.Element { + const shadowProps = useShadowPropsShort() + + const filteredRest = Object.fromEntries(Object.entries(rest).filter(([_, v]) => v !== undefined)) // Filter out undefined properties from rest + + const handleClose = useCallback( + (open: boolean) => { + if (!open && onClose) { + onClose() + } + }, + [onClose], + ) + + const isTopAligned = alignment === 'top' + + return ( + + + + + {adaptToSheet && + !isTopAligned && ( // Tamagui Sheets always animate in from the bottom, so we cannot use Sheets on top aligned modals + + + + + + )} + + + + + + + + {children} + + {bottomAttachment && {bottomAttachment}} + + + + + ) +} diff --git a/packages/ui/src/components/swipeablecards/ClickableWithinGesture.web.tsx b/packages/ui/src/components/swipeablecards/ClickableWithinGesture.web.tsx index 55cc6fefbe9..d0f0fb0eff7 100644 --- a/packages/ui/src/components/swipeablecards/ClickableWithinGesture.web.tsx +++ b/packages/ui/src/components/swipeablecards/ClickableWithinGesture.web.tsx @@ -8,5 +8,9 @@ export function ClickableWithinGesture({ onPress, children }: ClickableWithinGes onPress?.() } - return {children} + return ( + + {children} + + ) } diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 117a07076b0..fa39fd1cbaf 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -64,9 +64,6 @@ export * from './components/checkbox' export type { GeneratedIcon, IconProps } from './components/factories/createIcon' export * from './components/input/utils' export { Flex, Inset, Separator, flexStyles, type FlexProps } from './components/layout' -export { ContextMenu } from './components/menu/ContextMenu' -export { MenuContent } from './components/menu/MenuContent' -export type { MenuContentItem } from './components/menu/types' export { AdaptiveWebModal, WebBottomSheet } from './components/modal/AdaptiveWebModal' export * from './components/radio/Radio' export { ClickableWithinGesture } from './components/swipeablecards/ClickableWithinGesture' diff --git a/packages/ui/src/loading/Loader.tsx b/packages/ui/src/loading/Loader.tsx index a2df5b30274..49b423380fe 100644 --- a/packages/ui/src/loading/Loader.tsx +++ b/packages/ui/src/loading/Loader.tsx @@ -24,6 +24,13 @@ const Transaction = memo(function _Transaction({ repeat = 1 }: { repeat?: number ) }) +/** + * Loader used for search results e.g. search, recipient etc... + */ +const SearchResult = memo(function _SearchResult({ repeat = 1 }: { repeat?: number }): JSX.Element { + return +}) + const TransferInstitution = memo(function _TransferInstitution({ itemsCount, iconSize, @@ -130,6 +137,7 @@ export const Loader = { Box, NFT, Image, + SearchResult, Token, TransferInstitution, Transaction, diff --git a/packages/ui/src/theme/color/colors.ts b/packages/ui/src/theme/color/colors.ts index 352ee029fa2..1b8ccd74290 100644 --- a/packages/ui/src/theme/color/colors.ts +++ b/packages/ui/src/theme/color/colors.ts @@ -173,6 +173,7 @@ const sporeLight = { surface2: '#F9F9F9', surface2Hovered: '#F5F5F5', surface3: 'rgba(34,34,34,0.05)', + surface3Solid: '#F2F2F2', surface3Hovered: 'rgba(34,34,34,0.09)', surface4: 'rgba(255,255,255,0.64)', surface5: 'rgba(0,0,0,0.04)', @@ -219,6 +220,7 @@ const sporeDark = { surface2: '#1B1B1B', surface2Hovered: 'rgba(36,36,36,1.00)', surface3: 'rgba(255,255,255,0.12)', + surface3Solid: '#393939', surface3Hovered: 'rgba(255,255,255,0.16)', surface4: 'rgba(255,255,255,0.20)', surface5: 'rgba(0,0,0,0.04)', @@ -266,6 +268,7 @@ export const colorsLight = { surface2: sporeLight.surface2, surface2Hovered: sporeLight.surface2Hovered, surface3: sporeLight.surface3, + surface3Solid: sporeLight.surface3Solid, surface3Hovered: sporeLight.surface3Hovered, surface4: sporeLight.surface4, surface5: sporeLight.surface5, @@ -337,6 +340,7 @@ export const colorsDark = { surface2: sporeDark.surface2, surface2Hovered: sporeDark.surface2Hovered, surface3: sporeDark.surface3, + surface3Solid: sporeDark.surface3Solid, surface3Hovered: sporeDark.surface3Hovered, surface4: sporeDark.surface4, surface5: sporeDark.surface5, diff --git a/packages/ui/src/theme/color/utils.test.ts b/packages/ui/src/theme/color/utils.test.ts new file mode 100644 index 00000000000..96939057e64 --- /dev/null +++ b/packages/ui/src/theme/color/utils.test.ts @@ -0,0 +1,35 @@ +import { opacifyRaw } from 'ui/src/theme' + +describe(opacifyRaw, () => { + it.each` + amount | hexColor | expected + ${10} | ${'#aaaaaa'} | ${'#aaaaaa1a'} + ${0} | ${'#ffffff'} | ${'#ffffff00'} + ${100} | ${'#000000'} | ${'#000000ff'} + ${50} | ${'#123456'} | ${'#12345680'} + ${25} | ${'#abcdef'} | ${'#abcdef40'} + ${75} | ${'#fedcba'} | ${'#fedcbabf'} + ${0} | ${'#333333'} | ${'#33333300'} + ${100} | ${'#888888'} | ${'#888888ff'} + ${22.22} | ${'#888888'} | ${'#88888839'} + `('(amount=$amount, hexColor=$hexColor) should be expected=$expected', async ({ amount, hexColor, expected }) => { + expect(opacifyRaw(amount, hexColor).toLowerCase()).toEqual(expected.toLowerCase()) + }) + + it.each` + amount | hexColor | expectedError + ${110} | ${'#aaaaaa'} | ${'opacify: provided amount should be between 0 and 100'} + ${-10} | ${'#123456'} | ${'opacify: provided amount should be between 0 and 100'} + ${50} | ${'123456'} | ${null} + ${undefined} | ${'123456'} | ${null} + ${50} | ${undefined} | ${"Cannot read properties of undefined (reading 'startsWith')"} + ${50} | ${'#12'} | ${'opacify: provided color #12 was not in hexadecimal format (e.g. #000000)'} + ${50} | ${'#gggggg'} | ${'opacify: provided color #gggggg contains invalid characters, should be a valid hex (e.g. #000000)'} + `('should throw an error when (amount=$amount, hexColor=$hexColor)', async ({ amount, hexColor, expectedError }) => { + if (expectedError) { + expect(() => opacifyRaw(amount, hexColor)).toThrow(expectedError) + } else { + expect(opacifyRaw(amount, hexColor)).toEqual(hexColor) + } + }) +}) diff --git a/packages/ui/src/theme/color/utils.ts b/packages/ui/src/theme/color/utils.ts index ed07eaafa82..2c2e7b90f7f 100644 --- a/packages/ui/src/theme/color/utils.ts +++ b/packages/ui/src/theme/color/utils.ts @@ -18,6 +18,12 @@ export function opacifyRaw(amount: number, hexColor: string): string { throw new Error('opacify: provided amount should be between 0 and 100') } + const validHexColor = /^#[0-9A-Fa-f]{6}$/.test(hexColor) + if (!validHexColor) { + throw new Error( + `opacify: provided color ${hexColor} contains invalid characters, should be a valid hex (e.g. #000000)`, + ) + } const opacityHex = Math.round((amount / 100) * 255).toString(16) const opacifySuffix = opacityHex.length < 2 ? `0${opacityHex}` : opacityHex diff --git a/packages/uniswap/package.json b/packages/uniswap/package.json index f2f2075943e..46585281a28 100644 --- a/packages/uniswap/package.json +++ b/packages/uniswap/package.json @@ -42,14 +42,14 @@ "@typechain/ethers-v5": "7.2.0", "@uniswap/analytics-events": "2.38.0", "@uniswap/client-explore": "0.0.10", - "@uniswap/client-pools": "0.0.5", + "@uniswap/client-pools": "0.0.8", "@uniswap/permit2-sdk": "1.3.0", - "@uniswap/router-sdk": "1.14.2", - "@uniswap/sdk-core": "5.8.4", - "@uniswap/uniswapx-sdk": "^2.1.0-beta.14", - "@uniswap/v2-sdk": "4.6.0", - "@uniswap/v3-sdk": "3.17.0", - "@uniswap/v4-sdk": "1.10.0", + "@uniswap/router-sdk": "1.14.3", + "@uniswap/sdk-core": "5.9.0", + "@uniswap/uniswapx-sdk": "2.1.0-beta.18", + "@uniswap/v2-sdk": "4.6.1", + "@uniswap/v3-sdk": "3.18.1", + "@uniswap/v4-sdk": "1.10.3", "apollo-link-rest": "0.9.0", "axios": "1.6.5", "dayjs": "1.11.7", diff --git a/packages/uniswap/src/components/ConfirmSwapModal/ProgressIndicator.tsx b/packages/uniswap/src/components/ConfirmSwapModal/ProgressIndicator.tsx index 6f5618bd720..e4258d7cad1 100644 --- a/packages/uniswap/src/components/ConfirmSwapModal/ProgressIndicator.tsx +++ b/packages/uniswap/src/components/ConfirmSwapModal/ProgressIndicator.tsx @@ -78,5 +78,8 @@ function Step({ step, status }: { step: TransactionStep; status: StepStatus }): case TransactionStepType.IncreasePositionTransactionAsync: case TransactionStepType.DecreasePositionTransaction: return + case TransactionStepType.MigratePositionTransactionStep: + case TransactionStepType.MigratePositionTransactionStepAsync: + return } } diff --git a/packages/uniswap/src/components/ConfirmSwapModal/steps/Approve.tsx b/packages/uniswap/src/components/ConfirmSwapModal/steps/Approve.tsx index 9b0d12a1d91..0ebf7ed6ce0 100644 --- a/packages/uniswap/src/components/ConfirmSwapModal/steps/Approve.tsx +++ b/packages/uniswap/src/components/ConfirmSwapModal/steps/Approve.tsx @@ -12,7 +12,7 @@ export function TokenApprovalTransactionStepRow({ status, }: StepRowProps): JSX.Element { const { t } = useTranslation() - const { token } = step + const { token, pair } = step const symbol = token.symbol const title = { @@ -26,6 +26,7 @@ export function TokenApprovalTransactionStepRow({ ( @@ -16,7 +18,12 @@ const LPIcon = (): JSX.Element => ( ) -type LPSteps = IncreasePositionTransactionStep | IncreasePositionTransactionStepAsync | DecreasePositionTransactionStep +type LPSteps = + | IncreasePositionTransactionStep + | IncreasePositionTransactionStepAsync + | DecreasePositionTransactionStep + | MigratePositionTransactionStep + | MigratePositionTransactionStepAsync export function LPTransactionStepRow({ status }: StepRowProps): JSX.Element { const { t } = useTranslation() const colors = useSporeColors() diff --git a/packages/uniswap/src/components/ConfirmSwapModal/steps/StepRowSkeleton.tsx b/packages/uniswap/src/components/ConfirmSwapModal/steps/StepRowSkeleton.tsx index 157353d4bdf..c22f3b7f53e 100644 --- a/packages/uniswap/src/components/ConfirmSwapModal/steps/StepRowSkeleton.tsx +++ b/packages/uniswap/src/components/ConfirmSwapModal/steps/StepRowSkeleton.tsx @@ -6,6 +6,7 @@ import { PulseRipple } from 'ui/src/loading/PulseRipple' import { fonts, iconSizes, spacing } from 'ui/src/theme' import { StepStatus } from 'uniswap/src/components/ConfirmSwapModal/types' import { CurrencyLogo } from 'uniswap/src/components/CurrencyLogo/CurrencyLogo' +import { SplitLogo } from 'uniswap/src/components/CurrencyLogo/SplitLogo' import { useCurrencyInfo } from 'uniswap/src/features/tokens/useCurrencyInfo' import { TransactionStep } from 'uniswap/src/features/transactions/swap/types/steps' import { currencyId } from 'uniswap/src/utils/currencyId' @@ -18,6 +19,8 @@ export interface StepRowProps { interface StepRowSkeletonProps { /** If passed, the step row icon will be the currency logo. */ currency?: Currency + /** If passed, the step row icon will be the split currency logo. */ + pair?: [Currency, Currency] /** Icon to display if there is no currency to be displayed for this step. */ icon?: JSX.Element /** Color to display for the ripple effect around the icon or currency logo. This will default to a currency logo extracted color, if currency is defined. */ @@ -29,13 +32,22 @@ interface StepRowSkeletonProps { } export function StepRowSkeleton(props: StepRowSkeletonProps): JSX.Element { - const { currency, icon, secondsRemaining, title, learnMore, status, rippleColor } = props + const { currency, icon, secondsRemaining, title, learnMore, status, rippleColor, pair } = props const colors = useSporeColors() const currencyInfo = useCurrencyInfo(currency ? currencyId(currency) : undefined) + + // For V2 liquidity positions the user is generated a unique token which is + // the actual token they are approving, but since this token doesn't have + // a logo we use the SplitLogo component to display the pair logos instead. + const currency0Id = pair?.[0] ? currencyId(pair[0]) : undefined + const currency1Id = pair?.[1] ? currencyId(pair[1]) : undefined + const currency0Info = useCurrencyInfo(currency0Id) + const currency1Info = useCurrencyInfo(currency1Id) + const { tokenColor } = useExtractedTokenColor( - currencyInfo?.logoUrl, - currency?.symbol, + currency0Info ? currency0Info.logoUrl : currencyInfo?.logoUrl, + currency0Info ? currency0Info.currency.symbol : currency?.symbol, /*background=*/ colors.surface1.val, /*default=*/ colors.neutral3.val, ) @@ -46,7 +58,16 @@ export function StepRowSkeleton(props: StepRowSkeletonProps): JSX.Element { - {icon ?? } + {currency0Info && currency1Info ? ( + + ) : ( + icon ?? + )} diff --git a/packages/uniswap/src/components/CurrencyInputPanel/CurrencyInputPanel.tsx b/packages/uniswap/src/components/CurrencyInputPanel/CurrencyInputPanel.tsx index 05fc242a214..3f465d4294b 100644 --- a/packages/uniswap/src/components/CurrencyInputPanel/CurrencyInputPanel.tsx +++ b/packages/uniswap/src/components/CurrencyInputPanel/CurrencyInputPanel.tsx @@ -39,6 +39,7 @@ import { getSymbolDisplayText } from 'uniswap/src/utils/currency' import { isDetoxBuild } from 'utilities/src/environment/constants' import { NumberType } from 'utilities/src/format/types' import { usePrevious } from 'utilities/src/react/hooks' +import { ONE_SECOND_MS } from 'utilities/src/time/time' type CurrentInputPanelProps = { autoFocus?: boolean @@ -148,22 +149,36 @@ export const CurrencyInputPanel = memo( onToggleIsFiatMode(currencyField) }, [currencyField, onToggleIsFiatMode]) - // the focus state for native Inputs can sometimes be out of sync with the controlled `focus` - // prop. When the internal focus state differs from our `focus` prop, sync the internal - // focus state to be what our prop says it should be + // For native mobile, given that we're using a custom `DecimalPad`, + // the input's focus state can sometimes be out of sync with the controlled `focus` prop. + // When this happens, we want to sync the input's focus state by either auto-focusing or blurring it. const isTextInputRefActuallyFocused = inputRef.current?.isFocused() useEffect(() => { + if (isWeb) { + // We do not want to force-focus the `input` on web. + // This is only needed when using native mobile's custom `DecimalPad`. + return + } + + if (focus === undefined) { + // Ignore this effect unless `focus` is explicitly set to a boolean. + return + } + if (focus && !isTextInputRefActuallyFocused) { - inputRef.current?.focus() resetSelection?.({ start: value?.length ?? 0, end: value?.length ?? 0, currencyField, }) + setTimeout(() => { + // We need to wait for the token selector sheet to fully close before triggering this or else it won't work. + inputRef.current?.focus() + }, ONE_SECOND_MS / 2) } else if (!focus && isTextInputRefActuallyFocused) { inputRef.current?.blur() } - }, [currencyField, focus, inputRef, isTextInputRefActuallyFocused, resetSelection, value?.length]) + }, [currencyField, focus, isTextInputRefActuallyFocused, resetSelection, value?.length]) const { onLayout, fontSize, onSetFontSize } = useDynamicFontSizing( MAX_CHAR_PIXEL_WIDTH, @@ -281,7 +296,7 @@ export const CurrencyInputPanel = memo( autoFocus={autoFocus ?? focus} backgroundColor="$transparent" borderWidth={0} - color={color} + color={showInsufficientBalanceWarning ? '$statusCritical' : color} disabled={disabled || !currencyInfo} flex={1} focusable={!disabled && Boolean(currencyInfo)} diff --git a/packages/uniswap/src/components/CurrencyInputPanel/SelectTokenButton.tsx b/packages/uniswap/src/components/CurrencyInputPanel/SelectTokenButton.tsx index 866a1b2f21c..f4d8a6bac5e 100644 --- a/packages/uniswap/src/components/CurrencyInputPanel/SelectTokenButton.tsx +++ b/packages/uniswap/src/components/CurrencyInputPanel/SelectTokenButton.tsx @@ -49,7 +49,7 @@ export const SelectTokenButton = memo(function _SelectTokenButton({ backgroundColor={selectedCurrencyInfo ? '$surface1' : '$accent1'} borderRadius="$roundedFull" testID={testID} - borderColor="$surface3" + borderColor="$surface3Solid" borderWidth={1} shadowColor="$surface3" shadowRadius={10} diff --git a/packages/uniswap/src/components/InlineWarningCard/InlineWarningCard.tsx b/packages/uniswap/src/components/InlineWarningCard/InlineWarningCard.tsx index 83db0f8244d..3574764863a 100644 --- a/packages/uniswap/src/components/InlineWarningCard/InlineWarningCard.tsx +++ b/packages/uniswap/src/components/InlineWarningCard/InlineWarningCard.tsx @@ -16,6 +16,8 @@ type InlineWarningCardProps = { checked?: boolean setChecked?: (checked: boolean) => void hideCtaIcon?: boolean + headingTestId?: string + descriptionTestId?: string } export function InlineWarningCard({ @@ -28,7 +30,9 @@ export function InlineWarningCard({ checked, setChecked, hideCtaIcon, -}: InlineWarningCardProps): JSX.Element { + headingTestId, + descriptionTestId, +}: InlineWarningCardProps): JSX.Element | null { const tokenProtectionEnabled = useFeatureFlag(FeatureFlags.TokenProtection) const [checkedFallback, setCheckedFallback] = useState(false) const { color, textColor, backgroundColor } = getWarningIconColors(severity) @@ -43,6 +47,11 @@ export function InlineWarningCard({ } } + if (severity === WarningSeverity.None || !WarningIcon) { + // !WarningIcon for typecheck; should only be null if WarningSeverity == None + return null + } + const checkboxElement = checkboxLabel ? ( - + {description} {checkboxElement} } - heading={heading} + heading={ + heading && ( + + {heading} + + ) + } iconBackgroundColor={heroIcon ? backgroundColor : undefined} iconColor={color} onPressCtaButton={onPressCtaButton} diff --git a/packages/uniswap/src/components/MicroConfirmation.tsx b/packages/uniswap/src/components/MicroConfirmation.tsx new file mode 100644 index 00000000000..c76e6580748 --- /dev/null +++ b/packages/uniswap/src/components/MicroConfirmation.tsx @@ -0,0 +1,25 @@ +import { InfoTooltip } from 'uniswap/src/components/tooltip/InfoTooltip' +import { isInterface } from 'utilities/src/platform' + +type MicroConfirmationProps = { + /** Intended to be a micro toast/tooltip, text should not be more than 4 words */ + text: string + /** Overrides the default tooltip hover behavior; controls whether the tooltip should be displayed */ + showTooltip: boolean + trigger: JSX.Element + icon?: JSX.Element +} + +/** A tiny little confirmation notification that triggers after some action. + +- On web, this is a tooltip that only displays when show=true (not on hover) +- On mobile/extension, this is a micro notification toast + */ +export function MicroConfirmation({ text, showTooltip, trigger, icon }: MicroConfirmationProps): JSX.Element | null { + if (isInterface) { + return + } + // Not the greatest pattern, but callsite handles showing/hiding notification via dispatch(pushNotification(...)) + // There is an existing `CopiedNotification` set up in packages/wallet that handles the mobile/extension micro toast UI + return trigger +} diff --git a/packages/uniswap/src/components/TokenSelector/HorizontalTokenList/HorizontalTokenList.tsx b/packages/uniswap/src/components/TokenSelector/HorizontalTokenList/HorizontalTokenList.tsx index c4baf2702b2..d06bf43e3ef 100644 --- a/packages/uniswap/src/components/TokenSelector/HorizontalTokenList/HorizontalTokenList.tsx +++ b/packages/uniswap/src/components/TokenSelector/HorizontalTokenList/HorizontalTokenList.tsx @@ -7,6 +7,8 @@ export type HorizontalTokenListProps = { onSelectCurrency: OnSelectCurrency index: number section: TokenSection + expanded?: boolean + onExpand?: () => void } export const HorizontalTokenList = memo(function HorizontalTokenList(_props: HorizontalTokenListProps): JSX.Element { diff --git a/packages/uniswap/src/components/TokenSelector/HorizontalTokenList/HorizontalTokenList.web.tsx b/packages/uniswap/src/components/TokenSelector/HorizontalTokenList/HorizontalTokenList.web.tsx index ce1e4de61ab..0d12ecaab09 100644 --- a/packages/uniswap/src/components/TokenSelector/HorizontalTokenList/HorizontalTokenList.web.tsx +++ b/packages/uniswap/src/components/TokenSelector/HorizontalTokenList/HorizontalTokenList.web.tsx @@ -1,25 +1,74 @@ -import { memo } from 'react' -import { Flex } from 'ui/src/' +import { memo, useEffect, useRef, useState } from 'react' +import { Flex, Text, TouchableArea } from 'ui/src/' import { HorizontalTokenListProps } from 'uniswap/src/components/TokenSelector/HorizontalTokenList/HorizontalTokenList' -import { SuggestedToken } from 'uniswap/src/components/TokenSelector/SuggestedToken' +import { TokenCard } from 'uniswap/src/components/TokenSelector/TokenCard' + +const MAX_CARDS_PER_ROW = 5 export const HorizontalTokenList = memo(function _HorizontalTokenList({ tokens: suggestedTokens, onSelectCurrency, index, section, + expanded, + onExpand, }: HorizontalTokenListProps): JSX.Element { + const containerRef = useRef(null) + const [containerHeight, setContainerHeight] = useState(undefined) + + const shouldShowExpansion = suggestedTokens.length > MAX_CARDS_PER_ROW + const visibleTokens = shouldShowExpansion + ? expanded + ? suggestedTokens + : suggestedTokens.slice(0, MAX_CARDS_PER_ROW - 1) + : suggestedTokens + const remainingCount = shouldShowExpansion ? suggestedTokens.length - MAX_CARDS_PER_ROW + 1 : 0 + + // Hack to animate the height of the container when the tokens get expanded + useEffect(() => { + if (containerRef.current) { + setContainerHeight(containerRef.current.scrollHeight) + } + }, [visibleTokens]) + return ( - - {suggestedTokens.map((token) => ( - + + {visibleTokens.map((token) => ( + + + ))} + {!expanded && remainingCount > 0 && ( + onExpand?.()}> + + + {remainingCount}+ + + + + )} ) }) + +const styles = { + fiveTokenRowCard: { + width: 'calc(20% - 4px)', + }, +} diff --git a/packages/uniswap/src/components/TokenSelector/SuggestedToken.tsx b/packages/uniswap/src/components/TokenSelector/SuggestedToken.tsx index a1f791e9037..b4b8204c149 100644 --- a/packages/uniswap/src/components/TokenSelector/SuggestedToken.tsx +++ b/packages/uniswap/src/components/TokenSelector/SuggestedToken.tsx @@ -35,7 +35,7 @@ function _SuggestedToken({ onPress={onPress} > { + onSelectCurrency?.(token.currencyInfo, section, index) + } + + const tokenLabel = getSymbolDisplayText(currency.symbol) + + return ( + + + + + {tokenLabel} + + + + ) +} + +export const TokenCard = memo(_TokenCard) diff --git a/packages/uniswap/src/components/TokenSelector/TokenOptionItem.tsx b/packages/uniswap/src/components/TokenSelector/TokenOptionItem.tsx index e3f04ba356a..8859e377616 100644 --- a/packages/uniswap/src/components/TokenSelector/TokenOptionItem.tsx +++ b/packages/uniswap/src/components/TokenSelector/TokenOptionItem.tsx @@ -6,7 +6,7 @@ import { TokenLogo } from 'uniswap/src/components/CurrencyLogo/TokenLogo' import { TokenOption } from 'uniswap/src/components/TokenSelector/types' import { WarningSeverity } from 'uniswap/src/components/modals/WarningModal/types' import WarningIcon from 'uniswap/src/components/warnings/WarningIcon' -import { getWarningIconColorOverride } from 'uniswap/src/components/warnings/utils' +import { getWarningIconColors } from 'uniswap/src/components/warnings/utils' import { SafetyLevel } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' import { CurrencyInfo, TokenList } from 'uniswap/src/features/dataApi/types' import { FeatureFlags } from 'uniswap/src/features/gating/flags' @@ -24,7 +24,6 @@ interface OptionProps { onPress: () => void showTokenAddress?: boolean tokenWarningDismissed: boolean - dismissWarningCallback: () => void quantity: number | null // TODO(WEB-4731): Remove isKeyboardOpen dependency isKeyboardOpen?: boolean @@ -36,15 +35,12 @@ interface OptionProps { } function getTokenWarningDetails(currencyInfo: CurrencyInfo): { - severity: WarningSeverity | undefined - isWarningSevere: boolean + severity: WarningSeverity isNonDefaultList: boolean isBlocked: boolean } { const { safetyLevel, safetyInfo } = currencyInfo const severity = getTokenWarningSeverity(currencyInfo) - const isWarningSevere = - severity === WarningSeverity.Blocked || severity === WarningSeverity.High || severity === WarningSeverity.Medium const isNonDefaultList = safetyLevel === SafetyLevel.MediumWarning || safetyLevel === SafetyLevel.StrongWarning || @@ -52,7 +48,6 @@ function getTokenWarningDetails(currencyInfo: CurrencyInfo): { const isBlocked = severity === WarningSeverity.Blocked || safetyLevel === SafetyLevel.Blocked return { severity, - isWarningSevere, isNonDefaultList, isBlocked, } @@ -64,7 +59,6 @@ function _TokenOptionItem({ onPress, showTokenAddress, tokenWarningDismissed, - dismissWarningCallback, balance, quantity, quantityFormatted, @@ -76,11 +70,12 @@ function _TokenOptionItem({ const [showWarningModal, setShowWarningModal] = useState(false) const tokenProtectionEnabled = useFeatureFlag(FeatureFlags.TokenProtection) - const { severity, isBlocked, isNonDefaultList, isWarningSevere } = getTokenWarningDetails(currencyInfo) - const warningIconColor = getWarningIconColorOverride(severity) + const { severity, isBlocked, isNonDefaultList } = getTokenWarningDetails(currencyInfo) + // in token selector, we only show the warning icon if token is >=Medium severity + const { colorSecondary: warningIconColor } = getWarningIconColors(severity) const shouldShowWarningModalOnPress = !tokenProtectionEnabled ? isBlocked || (isNonDefaultList && !tokenWarningDismissed) - : isWarningSevere && !tokenWarningDismissed + : isBlocked || (severity !== WarningSeverity.None && !tokenWarningDismissed) const handleShowWarningModal = useCallback((): void => { dismissNativeKeyboard() @@ -105,10 +100,9 @@ function _TokenOptionItem({ }, [showWarnings, shouldShowWarningModalOnPress, onPress, isKeyboardOpen, handleShowWarningModal]) const onAcceptTokenWarning = useCallback(() => { - dismissWarningCallback() setShowWarningModal(false) onPress() - }, [dismissWarningCallback, onPress]) + }, [onPress]) return ( <> diff --git a/packages/uniswap/src/components/TokenSelector/TokenSectionBaseList.tsx b/packages/uniswap/src/components/TokenSelector/TokenSectionBaseList.tsx index dd14e0dfd1a..993544d7e4a 100644 --- a/packages/uniswap/src/components/TokenSelector/TokenSectionBaseList.tsx +++ b/packages/uniswap/src/components/TokenSelector/TokenSectionBaseList.tsx @@ -13,6 +13,7 @@ export interface ItemRowInfo { item: TokenOption | TokenOption[] section: TokenSection index: number + expanded?: boolean } export interface TokenSectionBaseListProps { @@ -23,6 +24,7 @@ export interface TokenSectionBaseListProps { renderItem: (info: ItemRowInfo) => JSX.Element | null renderSectionHeader?: (info: SectionRowInfo) => JSX.Element sections: TokenSection[] + expandedItems?: string[] } export function TokenSectionBaseList(_props: TokenSectionBaseListProps): JSX.Element { diff --git a/packages/uniswap/src/components/TokenSelector/TokenSectionBaseList.web.tsx b/packages/uniswap/src/components/TokenSelector/TokenSectionBaseList.web.tsx index 1c4161bcb96..9f07eebbcb3 100644 --- a/packages/uniswap/src/components/TokenSelector/TokenSectionBaseList.web.tsx +++ b/packages/uniswap/src/components/TokenSelector/TokenSectionBaseList.web.tsx @@ -1,6 +1,8 @@ import isArray from 'lodash/isArray' import isEqual from 'lodash/isEqual' import React, { CSSProperties, Key, useCallback, useEffect, useMemo, useRef, useState } from 'react' +// eslint-disable-next-line no-restricted-imports +import { LayoutChangeEvent } from 'react-native' import AutoSizer from 'react-virtualized-auto-sizer' import { VariableSizeList as List } from 'react-window' import { Flex, useWindowDimensions } from 'ui/src' @@ -39,6 +41,7 @@ export function TokenSectionBaseList({ renderSectionHeader, sections, sectionListRef, + expandedItems, }: TokenSectionBaseListProps): JSX.Element { const ref = useRef(null) const rowHeightMap = useRef<{ [key: number]: number }>({}) @@ -81,23 +84,21 @@ export function TokenSectionBaseList({ index, key: keyExtractor?.(item, index), renderItem, + expanded: expandedItems?.includes(keyExtractor?.(item, index) ?? '') ?? false, } return itemInfo }), ) }, []) - }, [sections, renderSectionHeader, keyExtractor, renderItem]) + }, [sections, renderSectionHeader, keyExtractor, renderItem, expandedItems]) + // Used for rendering the sticky header const activeSessionIndex = useMemo(() => { return items.slice(0, firstVisibleIndex + 1).reduceRight((acc, item, index) => { return acc === -1 && isSectionHeader(item) ? index : acc }, -1) }, [firstVisibleIndex, items]) - useEffect(() => { - rowHeightMap.current = {} - }, [items]) - const updateRowHeight = useCallback((index: number, height: number) => { if (rowHeightMap.current[index] !== height) { rowHeightMap.current[index] = height @@ -220,23 +221,22 @@ type RowProps = { windowWidth: number updateRowHeight?: (index: number, height: number) => void } -function _Row({ index, itemData, style, windowWidth, updateRowHeight }: RowProps): JSX.Element { +function _Row({ index, itemData, style, updateRowHeight }: RowProps): JSX.Element { const rowRef = useRef(null) - useEffect(() => { - // We need to run this in the next tick to get the correct height. - setTimeout(() => { - const height = rowRef.current?.getBoundingClientRect().height - if (!height || !updateRowHeight) { - return + const handleLayout = useCallback( + (e: LayoutChangeEvent) => { + const height = e.nativeEvent.layout.height + if (height && updateRowHeight) { + updateRowHeight(index, height) } - updateRowHeight(index, height) - }, 0) - }, [updateRowHeight, index, windowWidth, itemData.key]) + }, + [updateRowHeight, index], + ) return ( - + {itemData && (isSectionHeader(itemData) ? itemData.renderSectionHeader?.(itemData) : itemData.renderItem(itemData))} diff --git a/packages/uniswap/src/components/TokenSelector/TokenSelector.tsx b/packages/uniswap/src/components/TokenSelector/TokenSelector.tsx index d12d884b38a..be25f4e3263 100644 --- a/packages/uniswap/src/components/TokenSelector/TokenSelector.tsx +++ b/packages/uniswap/src/components/TokenSelector/TokenSelector.tsx @@ -146,6 +146,7 @@ export function TokenSelectorContent({ position: searchContext.position, suggestion_count: searchContext.suggestionCount, query: searchContext.query, + tokenSection: section.sectionKey, }) const isBridgePair = section.sectionKey === TokenOptionSection.BridgingTokens diff --git a/packages/uniswap/src/components/TokenSelector/TokenSelectorList.tsx b/packages/uniswap/src/components/TokenSelector/TokenSelectorList.tsx index 9305c724ffa..eb143ca24c8 100644 --- a/packages/uniswap/src/components/TokenSelector/TokenSelectorList.tsx +++ b/packages/uniswap/src/components/TokenSelector/TokenSelectorList.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useEffect, useRef } from 'react' +import { memo, useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { AnimateTransition, Flex, Loader, Skeleton, Text } from 'ui/src' import { fonts } from 'ui/src/theme' @@ -49,9 +49,7 @@ const TokenOptionItemWrapper = memo(function _TokenOptionItemWrapper({ const { isTestnetModeEnabled } = useEnabledChains() - const { tokenWarningDismissed, onDismissTokenWarning: dismissWarningCallback } = useDismissedTokenWarnings( - tokenOption.currencyInfo.currency, - ) + const { tokenWarningDismissed } = useDismissedTokenWarnings(tokenOption.currencyInfo.currency) const tokenBalance = formatNumberOrString({ value: tokenOption.quantity, @@ -66,7 +64,6 @@ const TokenOptionItemWrapper = memo(function _TokenOptionItemWrapper({ return ( () - + const [expandedItems, setExpandedItems] = useState([]) useEffect(() => { if (sections?.length) { sectionListRef.current?.scrollToLocation({ @@ -130,10 +127,34 @@ function _TokenSelectorList({ } }, [chainFilter, sections?.length]) + const handleExpand = useCallback( + (item: TokenOption | TokenOption[]) => { + setExpandedItems((prev) => [...prev, key(item)]) + }, + [setExpandedItems], + ) + + const isExpandedItem = useCallback( + (item: TokenOption[]) => { + return expandedItems.includes(key(item)) + }, + [expandedItems], + ) + + // Note: the typing for this comes from the web TokenSectionBaseList.tsx's renderItem const renderItem = useCallback( ({ item, section, index }: { item: TokenOption | TokenOption[]; section: TokenSection; index: number }) => { if (isHorizontalListTokenItem(item)) { - return + return ( + handleExpand(item)} + /> + ) } return ( ) }, - [onSelectCurrency, showTokenAddress, showTokenWarnings, isKeyboardOpen], + [onSelectCurrency, showTokenAddress, showTokenWarnings, isKeyboardOpen, handleExpand, isExpandedItem], ) const renderSectionHeader = useCallback( @@ -199,6 +220,7 @@ function _TokenSelectorList({ renderSectionHeader={renderSectionHeader} sectionListRef={sectionListRef} sections={sections ?? []} + expandedItems={expandedItems} /> ) diff --git a/packages/uniswap/src/components/TokenSelector/hooks.test.ts b/packages/uniswap/src/components/TokenSelector/hooks.test.ts index 779e1935677..bf7c0545a3e 100644 --- a/packages/uniswap/src/components/TokenSelector/hooks.test.ts +++ b/packages/uniswap/src/components/TokenSelector/hooks.test.ts @@ -16,7 +16,7 @@ import { import { TokenSelectorFlow } from 'uniswap/src/components/TokenSelector/types' import { createEmptyBalanceOption } from 'uniswap/src/components/TokenSelector/utils' import { BRIDGED_BASE_ADDRESSES } from 'uniswap/src/constants/addresses' -import { Chain } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' +import { Chain, SafetyLevel } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' import { fromGraphQLChain } from 'uniswap/src/features/chains/utils' import { tokenProjectToCurrencyInfos } from 'uniswap/src/features/dataApi/utils' import { UniswapState } from 'uniswap/src/state/uniswapReducer' @@ -49,12 +49,12 @@ jest.mock('uniswap/src/features/telemetry/send') const eth = ethToken() const dai = daiToken() -const usdc = usdcBaseToken() +const usdc_base = usdcBaseToken() const ethBalance = tokenBalance({ token: eth }) const daiBalance = tokenBalance({ token: dai }) -const usdcBalance = tokenBalance({ token: usdc }) -const favoriteTokens = [eth, dai, usdc] -const favoriteTokenBalances = [ethBalance, daiBalance, usdcBalance] +const usdcBaseBalance = tokenBalance({ token: usdc_base }) +const favoriteTokens = [eth, dai, usdc_base] +const favoriteTokenBalances = [ethBalance, daiBalance, usdcBaseBalance] const favoriteCurrencyIds = favoriteTokens.map((t) => buildCurrencyId(fromGraphQLChain(t.chain) ?? UniverseChainId.Mainnet, t.address), @@ -139,11 +139,12 @@ describe(useAllCommonBaseCurrencies, () => { describe(useFavoriteCurrencies, () => { const project = tokenProject({ // Add some more tokens to check if favorite tokens are filtered properly - tokens: [usdcArbitrumToken(), usdcBaseToken(), ...favoriteTokens, usdcToken()], + tokens: [usdcArbitrumToken(), usdcToken(), ...favoriteTokens], + safetyLevel: SafetyLevel.Verified, }) const projectWithFavoritesOnly = tokenProject({ - ...project, tokens: favoriteTokens, + safetyLevel: SafetyLevel.Verified, }) const cases = [ @@ -616,8 +617,8 @@ describe(usePopularTokensOptions, () => { }) describe(useCommonTokensOptionsWithFallback, () => { - const tokens = [eth, dai, usdc] - const tokenBalances = [ethBalance, daiBalance, usdcBalance] + const tokens = [eth, dai, usdc_base] + const tokenBalances = [ethBalance, daiBalance, usdcBaseBalance] const cases = [ { diff --git a/packages/uniswap/src/components/TokenSelector/hooks.tsx b/packages/uniswap/src/components/TokenSelector/hooks.tsx index 460c0a18672..2bb8623a25a 100644 --- a/packages/uniswap/src/components/TokenSelector/hooks.tsx +++ b/packages/uniswap/src/components/TokenSelector/hooks.tsx @@ -21,7 +21,7 @@ import { import { BRIDGED_BASE_ADDRESSES, getNativeAddress } from 'uniswap/src/constants/addresses' import { UNIVERSE_CHAIN_INFO } from 'uniswap/src/constants/chains' import { COMMON_BASES } from 'uniswap/src/constants/routing' -import { DAI, USDC, USDT, WBTC } from 'uniswap/src/constants/tokens' +import { USDC, USDT, WBTC } from 'uniswap/src/constants/tokens' import { SafetyLevel } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' import { GqlResult } from 'uniswap/src/data/types' import { TradeableAsset } from 'uniswap/src/entities/assets' @@ -35,7 +35,12 @@ import { useSearchTokens } from 'uniswap/src/features/dataApi/searchTokens' import { useTokenProjects } from 'uniswap/src/features/dataApi/tokenProjects' import { usePopularTokens as usePopularTokensGql } from 'uniswap/src/features/dataApi/topTokens' import { CurrencyInfo, PortfolioBalance } from 'uniswap/src/features/dataApi/types' -import { buildCurrency, gqlTokenToCurrencyInfo, usePersistedError } from 'uniswap/src/features/dataApi/utils' +import { + buildCurrency, + buildCurrencyInfo, + gqlTokenToCurrencyInfo, + usePersistedError, +} from 'uniswap/src/features/dataApi/utils' import { selectFavoriteTokens } from 'uniswap/src/features/favorites/selectors' import { FeatureFlags } from 'uniswap/src/features/gating/flags' import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' @@ -71,7 +76,6 @@ const baseCurrencyIds = [ buildNativeCurrencyId(UniverseChainId.Bnb), buildNativeCurrencyId(UniverseChainId.Celo), buildNativeCurrencyId(UniverseChainId.Avalanche), - currencyId(DAI), currencyId(USDC), currencyId(USDT), currencyId(WBTC), @@ -98,6 +102,7 @@ export function searchResultToCurrencyInfo({ logoUrl, safetyLevel, safetyInfo, + feeData, }: TokenSearchResult): CurrencyInfo | null { const currency = buildCurrency({ chainId: chainId as UniverseChainId, @@ -105,13 +110,15 @@ export function searchResultToCurrencyInfo({ decimals: 0, // this does not matter in a context of CurrencyInfo here, as we do not provide any balance symbol, name, + buyFeeBps: feeData?.buyFeeBps, + sellFeeBps: feeData?.sellFeeBps, }) if (!currency) { return null } - const currencyInfo: CurrencyInfo = { + return buildCurrencyInfo({ currency, currencyId: currencyId(currency), logoUrl, @@ -119,8 +126,7 @@ export function searchResultToCurrencyInfo({ // defaulting to not spam, as user has searched and chosen this token before isSpam: false, safetyInfo, - } - return currencyInfo + }) } export function useAllCommonBaseCurrencies(): GqlResult { @@ -227,6 +233,12 @@ function currencyInfoToTokenSearchResult(currencyInfo: CurrencyInfo): TokenSearc logoUrl: currencyInfo.logoUrl ?? null, safetyLevel: currencyInfo.safetyLevel ?? null, safetyInfo: currencyInfo.safetyInfo, + feeData: currencyInfo.currency.isToken + ? { + buyFeeBps: currencyInfo.currency.buyFeeBps?.gt(0) ? currencyInfo.currency.buyFeeBps.toString() : undefined, + sellFeeBps: currencyInfo.currency.sellFeeBps?.gt(0) ? currencyInfo.currency.sellFeeBps.toString() : undefined, + } + : null, } } diff --git a/packages/uniswap/src/components/banners/TestnetModeBanner.tsx b/packages/uniswap/src/components/banners/TestnetModeBanner.tsx index 367f9c92a14..c567effd69c 100644 --- a/packages/uniswap/src/components/banners/TestnetModeBanner.tsx +++ b/packages/uniswap/src/components/banners/TestnetModeBanner.tsx @@ -3,8 +3,7 @@ import { Flex, FlexProps, Text, isWeb } from 'ui/src' import { Wrench } from 'ui/src/components/icons/Wrench' // eslint-disable-next-line no-restricted-imports import { useDeviceInsets } from 'ui/src/hooks/useDeviceInsets' -import { useEnabledChains } from 'uniswap/src/features/settings/hooks' -import { TESTNET_MODE_BANNER_HEIGHT } from 'uniswap/src/hooks/useAppInsets' +import { TESTNET_MODE_BANNER_HEIGHT, useEnabledChains } from 'uniswap/src/features/settings/hooks' import { isInterface, isMobileApp } from 'utilities/src/platform' export function TestnetModeBanner(props: FlexProps): JSX.Element | null { diff --git a/packages/uniswap/src/components/dropdowns/ActionSheetDropdown.tsx b/packages/uniswap/src/components/dropdowns/ActionSheetDropdown.tsx index 9dc114c6fbe..b40195649ed 100644 --- a/packages/uniswap/src/components/dropdowns/ActionSheetDropdown.tsx +++ b/packages/uniswap/src/components/dropdowns/ActionSheetDropdown.tsx @@ -42,6 +42,7 @@ export type ActionSheetDropdownStyleProps = { buttonPaddingX?: FlexProps['px'] buttonPaddingY?: FlexProps['py'] dropdownMaxHeight?: number + dropdownMinWidth?: number dropdownZIndex?: FlexProps['zIndex'] } @@ -190,6 +191,7 @@ const ActionSheetBackdropWithContent = memo(function ActionSheetBackdropWithCont {...contentProps} alignment={styles?.alignment} dropdownMaxHeight={styles?.dropdownMaxHeight} + dropdownMinWidth={styles?.dropdownMinWidth} handleClose={closeDropdown} toggleMeasurements={toggleMeasurements} closeOnSelect={closeOnSelect} @@ -205,6 +207,7 @@ type DropdownContentProps = FlexProps & { options: MenuItemProp[] alignment?: 'left' | 'right' dropdownMaxHeight?: number + dropdownMinWidth?: number toggleMeasurements: LayoutMeasurements & { sticky?: boolean } handleClose?: () => void closeOnSelect: boolean @@ -232,6 +235,7 @@ function DropdownContent({ options, alignment = 'left', dropdownMaxHeight, + dropdownMinWidth, toggleMeasurements, handleClose, closeOnSelect, @@ -300,10 +304,10 @@ function DropdownContent({ - + ) } diff --git a/packages/uniswap/src/components/modals/ModalProps.tsx b/packages/uniswap/src/components/modals/ModalProps.tsx index 5e9cb7f14d8..a821aedaae6 100644 --- a/packages/uniswap/src/components/modals/ModalProps.tsx +++ b/packages/uniswap/src/components/modals/ModalProps.tsx @@ -1,5 +1,5 @@ import { BottomSheetModal as BaseModal } from '@gorhom/bottom-sheet' -import { ComponentProps, PropsWithChildren } from 'react' +import { ComponentProps, PropsWithChildren, ReactNode } from 'react' import { SharedValue } from 'react-native-reanimated' import { ColorTokens, SpaceTokens, View } from 'ui/src' import { ModalNameType } from 'uniswap/src/features/telemetry/constants' @@ -35,4 +35,5 @@ export type ModalProps = PropsWithChildren<{ maxWidth?: number maxHeight?: ComponentProps['maxHeight'] padding?: SpaceTokens + bottomAttachment?: ReactNode }> diff --git a/packages/uniswap/src/components/modals/WarningModal/WarningInfo.tsx b/packages/uniswap/src/components/modals/WarningModal/WarningInfo.tsx index 17dd16c5089..5b029e8ea84 100644 --- a/packages/uniswap/src/components/modals/WarningModal/WarningInfo.tsx +++ b/packages/uniswap/src/components/modals/WarningModal/WarningInfo.tsx @@ -2,11 +2,11 @@ import { PropsWithChildren, ReactNode, useState } from 'react' import { Flex, TouchableArea, isWeb } from 'ui/src' import { InfoCircle } from 'ui/src/components/icons/InfoCircle' import { WarningModal, WarningModalProps } from 'uniswap/src/components/modals/WarningModal/WarningModal' -import { WarningTooltip } from 'uniswap/src/components/modals/WarningModal/WarningTooltip' -import { WarningTooltipProps } from 'uniswap/src/components/modals/WarningModal/WarningTooltipProps' +import { InfoTooltip } from 'uniswap/src/components/tooltip/InfoTooltip' +import { InfoTooltipProps } from 'uniswap/src/components/tooltip/InfoTooltipProps' type WarningInfoProps = { - tooltipProps: Omit + tooltipProps: Omit modalProps: Omit infoButton?: ReactNode trigger?: ReactNode @@ -28,9 +28,9 @@ export function WarningInfo({ if (isWeb) { return ( - + {children} - + ) } diff --git a/packages/uniswap/src/components/modals/WarningModal/WarningModal.tsx b/packages/uniswap/src/components/modals/WarningModal/WarningModal.tsx index 3e81e7bf5ed..42afec2821b 100644 --- a/packages/uniswap/src/components/modals/WarningModal/WarningModal.tsx +++ b/packages/uniswap/src/components/modals/WarningModal/WarningModal.tsx @@ -7,7 +7,8 @@ import { ThemeNames, opacify } from 'ui/src/theme' import { Modal } from 'uniswap/src/components/modals/Modal' import { getAlertColor } from 'uniswap/src/components/modals/WarningModal/getAlertColor' import { WarningSeverity } from 'uniswap/src/components/modals/WarningModal/types' -import { ModalNameType } from 'uniswap/src/features/telemetry/constants' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { ElementName, ModalNameType } from 'uniswap/src/features/telemetry/constants' import { SwapFormContext } from 'uniswap/src/features/transactions/swap/contexts/SwapFormContext' import { TestID } from 'uniswap/src/test/fixtures/testIDs' import { isWeb } from 'utilities/src/platform' @@ -17,6 +18,7 @@ type WarningModalContentProps = { onReject?: () => void onAcknowledge?: () => void hideHandlebar?: boolean + modalName: ModalNameType title?: string titleComponent?: ReactNode caption?: string @@ -37,13 +39,13 @@ type WarningModalContentProps = { export type WarningModalProps = { isOpen: boolean isDismissible?: boolean - modalName: ModalNameType } & WarningModalContentProps export function WarningModalContent({ onClose, onReject, onAcknowledge, + modalName, title, titleComponent, caption, @@ -105,25 +107,29 @@ export function WarningModalContent({ {children} {rejectText && ( - + + + )} {acknowledgeText && ( - + + + )} diff --git a/packages/uniswap/src/components/modals/WarningModal/WarningTooltip.native.tsx b/packages/uniswap/src/components/modals/WarningModal/WarningTooltip.native.tsx deleted file mode 100644 index 70f967b9bd9..00000000000 --- a/packages/uniswap/src/components/modals/WarningModal/WarningTooltip.native.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import { PropsWithChildren } from 'react' -import { WarningTooltipProps } from 'uniswap/src/components/modals/WarningModal/WarningTooltipProps' -import { NotImplementedError } from 'utilities/src/errors' - -export function WarningTooltip(_props: PropsWithChildren): JSX.Element { - throw new NotImplementedError('WarningTooltip') -} diff --git a/packages/uniswap/src/components/modals/WarningModal/WarningTooltip.tsx b/packages/uniswap/src/components/modals/WarningModal/WarningTooltip.tsx deleted file mode 100644 index 48bfba82dc4..00000000000 --- a/packages/uniswap/src/components/modals/WarningModal/WarningTooltip.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import { PropsWithChildren } from 'react' -import { WarningTooltipProps } from 'uniswap/src/components/modals/WarningModal/WarningTooltipProps' -import { PlatformSplitStubError } from 'utilities/src/errors' - -export function WarningTooltip(_props: PropsWithChildren): JSX.Element { - throw new PlatformSplitStubError('WarningTooltip') -} diff --git a/packages/uniswap/src/components/modals/WarningModal/getAlertColor.ts b/packages/uniswap/src/components/modals/WarningModal/getAlertColor.ts index 7af2208e529..d451c544405 100644 --- a/packages/uniswap/src/components/modals/WarningModal/getAlertColor.ts +++ b/packages/uniswap/src/components/modals/WarningModal/getAlertColor.ts @@ -5,36 +5,42 @@ export function getAlertColor(severity?: WarningSeverity): WarningColor { case WarningSeverity.None: return { text: '$neutral2', + headerText: '$neutral1', background: '$neutral2', buttonTheme: 'secondary', } case WarningSeverity.Low: return { text: '$neutral2', + headerText: '$neutral1', background: '$surface2', buttonTheme: 'tertiary', } case WarningSeverity.High: return { text: '$statusCritical', + headerText: '$statusCritical', background: '$DEP_accentCriticalSoft', buttonTheme: 'detrimental', } case WarningSeverity.Medium: return { text: '$DEP_accentWarning', + headerText: '$DEP_accentWarning', background: '$DEP_accentWarningSoft', buttonTheme: 'warning', } case WarningSeverity.Blocked: return { text: '$neutral1', + headerText: '$neutral1', background: '$surface3', buttonTheme: 'secondary', } default: return { text: '$neutral2', + headerText: '$neutral1', background: '$transparent', buttonTheme: 'tertiary', } diff --git a/packages/uniswap/src/components/modals/WarningModal/types.ts b/packages/uniswap/src/components/modals/WarningModal/types.ts index 79c0419eae9..8569acc8c2a 100644 --- a/packages/uniswap/src/components/modals/WarningModal/types.ts +++ b/packages/uniswap/src/components/modals/WarningModal/types.ts @@ -12,6 +12,7 @@ export enum WarningSeverity { export type WarningColor = { text: ColorTokens + headerText: ColorTokens background: ColorTokens buttonTheme: ThemeNames } diff --git a/packages/uniswap/src/components/network/NetworkFilter.tsx b/packages/uniswap/src/components/network/NetworkFilter.tsx index 910ca943866..9010d2789c9 100644 --- a/packages/uniswap/src/components/network/NetworkFilter.tsx +++ b/packages/uniswap/src/components/network/NetworkFilter.tsx @@ -113,6 +113,7 @@ export function NetworkFilter({ showArrow={!hideArrow} styles={{ alignment: 'right', + buttonPaddingY: '$none', ...styles, }} testID="chain-selector" diff --git a/packages/uniswap/src/components/network/__snapshots__/NetworkFilter.test.tsx.snap b/packages/uniswap/src/components/network/__snapshots__/NetworkFilter.test.tsx.snap index 8ced283f72e..3ca14bac24f 100644 --- a/packages/uniswap/src/components/network/__snapshots__/NetworkFilter.test.tsx.snap +++ b/packages/uniswap/src/components/network/__snapshots__/NetworkFilter.test.tsx.snap @@ -35,8 +35,8 @@ exports[`NetworkFilter renders a NetworkFilter 1`] = ` "flexDirection": "row", "gap": 8, "justifyContent": "center", - "paddingBottom": 8, - "paddingTop": 8, + "paddingBottom": 0, + "paddingTop": 0, } } testID="chain-selector" diff --git a/packages/uniswap/src/components/text/LearnMoreLink.tsx b/packages/uniswap/src/components/text/LearnMoreLink.tsx index 38cba388e2e..840f7bfcc45 100644 --- a/packages/uniswap/src/components/text/LearnMoreLink.tsx +++ b/packages/uniswap/src/components/text/LearnMoreLink.tsx @@ -10,15 +10,17 @@ export const LearnMoreLink = ({ url, textVariant = 'buttonLabel2', textColor = '$accent1', + centered = false, }: { url: string textVariant?: TextProps['variant'] textColor?: TextProps['color'] + centered?: boolean }): JSX.Element => { const { t } = useTranslation() return ( => onPressLearnMore(url)}> - + {t('common.button.learn')} diff --git a/packages/uniswap/src/components/tooltip/InfoTooltip.native.tsx b/packages/uniswap/src/components/tooltip/InfoTooltip.native.tsx new file mode 100644 index 00000000000..5ce362f6141 --- /dev/null +++ b/packages/uniswap/src/components/tooltip/InfoTooltip.native.tsx @@ -0,0 +1,7 @@ +import { PropsWithChildren } from 'react' +import { InfoTooltipProps } from 'uniswap/src/components/tooltip/InfoTooltipProps' +import { NotImplementedError } from 'utilities/src/errors' + +export function InfoTooltip(_props: PropsWithChildren): JSX.Element { + throw new NotImplementedError('InfoTooltip') +} diff --git a/packages/uniswap/src/components/tooltip/InfoTooltip.tsx b/packages/uniswap/src/components/tooltip/InfoTooltip.tsx new file mode 100644 index 00000000000..c538f9e981e --- /dev/null +++ b/packages/uniswap/src/components/tooltip/InfoTooltip.tsx @@ -0,0 +1,7 @@ +import { PropsWithChildren } from 'react' +import { InfoTooltipProps } from 'uniswap/src/components/tooltip/InfoTooltipProps' +import { PlatformSplitStubError } from 'utilities/src/errors' + +export function InfoTooltip(_props: PropsWithChildren): JSX.Element { + throw new PlatformSplitStubError('InfoTooltip') +} diff --git a/packages/uniswap/src/components/modals/WarningModal/WarningTooltip.web.tsx b/packages/uniswap/src/components/tooltip/InfoTooltip.web.tsx similarity index 67% rename from packages/uniswap/src/components/modals/WarningModal/WarningTooltip.web.tsx rename to packages/uniswap/src/components/tooltip/InfoTooltip.web.tsx index 12668294b5b..df90032b32f 100644 --- a/packages/uniswap/src/components/modals/WarningModal/WarningTooltip.web.tsx +++ b/packages/uniswap/src/components/tooltip/InfoTooltip.web.tsx @@ -1,11 +1,11 @@ import { PropsWithChildren } from 'react' import { Flex, Text, Tooltip, isWeb } from 'ui/src' -import { WarningTooltipProps } from 'uniswap/src/components/modals/WarningModal/WarningTooltipProps' +import { InfoTooltipProps } from 'uniswap/src/components/tooltip/InfoTooltipProps' const TOOLTIP_REST_MS = 20 const TOOLTIP_CLOSE_MS = 100 -export function WarningTooltip({ +export function InfoTooltip({ title, text, icon, @@ -15,11 +15,17 @@ export function WarningTooltip({ children, maxWidth, placement, -}: PropsWithChildren): JSX.Element { + open, +}: PropsWithChildren): JSX.Element { return ( {triggerPlacement === 'end' && children} - + {trigger} @@ -33,9 +39,11 @@ export function WarningTooltip({ {text} - - {button} - + {button && ( + + {button} + + )} diff --git a/packages/uniswap/src/components/modals/WarningModal/WarningTooltipProps.ts b/packages/uniswap/src/components/tooltip/InfoTooltipProps.ts similarity index 57% rename from packages/uniswap/src/components/modals/WarningModal/WarningTooltipProps.ts rename to packages/uniswap/src/components/tooltip/InfoTooltipProps.ts index 0dd1864f331..8e4a795769d 100644 --- a/packages/uniswap/src/components/modals/WarningModal/WarningTooltipProps.ts +++ b/packages/uniswap/src/components/tooltip/InfoTooltipProps.ts @@ -1,12 +1,14 @@ import { ReactNode } from 'react' import { PopperProps } from 'ui/src' -export type WarningTooltipProps = { +export type InfoTooltipProps = { title?: string text: ReactNode icon?: Maybe - button: ReactNode + button?: ReactNode trigger: ReactNode triggerPlacement?: 'start' | 'end' maxWidth?: number + /** By default, tooltip will automatically open/close on hover. Set this prop to manually control open/close. */ + open?: boolean } & Pick diff --git a/packages/uniswap/src/components/warnings/WarningIcon.tsx b/packages/uniswap/src/components/warnings/WarningIcon.tsx index 248c274dcbd..5a0483243ee 100644 --- a/packages/uniswap/src/components/warnings/WarningIcon.tsx +++ b/packages/uniswap/src/components/warnings/WarningIcon.tsx @@ -11,6 +11,7 @@ import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' interface Props { // TODO (WALL-4626): remove SafetyLevel entirely + /** @deprecated use severity instead */ safetyLevel?: Maybe severity?: WarningSeverity // To override the normally associated safetyLevel<->color mapping @@ -30,7 +31,7 @@ export default function WarningIcon({ const { color: defaultIconColor, backgroundColor } = getWarningIconColors(severityToUse) const color = strokeColorOverride ?? defaultIconColor const Icon = getWarningIcon(severityToUse, tokenProtectionEnabled) - const icon = + const icon = Icon ? : null return heroIcon ? ( {icon} diff --git a/packages/uniswap/src/components/warnings/utils.ts b/packages/uniswap/src/components/warnings/utils.ts index b38d30e183e..24b1668d54a 100644 --- a/packages/uniswap/src/components/warnings/utils.ts +++ b/packages/uniswap/src/components/warnings/utils.ts @@ -21,7 +21,11 @@ export function safetyLevelToWarningSeverity(safetyLevel: Maybe): W } } -export function getWarningIcon(severity?: WarningSeverity, tokenProtectionEnabled: boolean = false): GeneratedIcon { +// eslint-disable-next-line consistent-return +export function getWarningIcon( + severity: WarningSeverity, + tokenProtectionEnabled: boolean = false, +): GeneratedIcon | null { switch (severity) { case WarningSeverity.High: return tokenProtectionEnabled ? OctagonExclamation : AlertTriangleFilled @@ -30,15 +34,16 @@ export function getWarningIcon(severity?: WarningSeverity, tokenProtectionEnable case WarningSeverity.Blocked: return Blocked case WarningSeverity.Low: - case WarningSeverity.None: return InfoCircleFilled - default: - return AlertTriangleFilled + case WarningSeverity.None: + return null } } export function getWarningIconColors(severity?: WarningSeverity): { color: ColorTokens + /** `colorSecondary` used instead of `color` in certain places, such as token selector & mobile search */ + colorSecondary: ColorTokens | undefined backgroundColor: ColorTokens textColor: ColorTokens } { @@ -46,21 +51,30 @@ export function getWarningIconColors(severity?: WarningSeverity): { case WarningSeverity.High: return { color: '$statusCritical', + colorSecondary: '$statusCritical', backgroundColor: '$DEP_accentCriticalSoft', textColor: '$statusCritical', } case WarningSeverity.Medium: return { color: '$DEP_accentWarning', + colorSecondary: '$neutral2', backgroundColor: '$DEP_accentWarningSoft', textColor: '$DEP_accentWarning', } case WarningSeverity.Blocked: + return { + color: '$neutral2', + colorSecondary: '$neutral2', + backgroundColor: '$surface3', + textColor: '$neutral1', + } case WarningSeverity.Low: case WarningSeverity.None: default: return { color: '$neutral2', + colorSecondary: undefined, backgroundColor: '$surface3', textColor: '$neutral1', } @@ -85,17 +99,3 @@ export function getWarningButtonProps(severity?: WarningSeverity): { theme: Them } } } - -export function getWarningIconColorOverride(severity?: WarningSeverity): ColorTokens | undefined { - switch (severity) { - case WarningSeverity.High: - return '$statusCritical' - case WarningSeverity.Medium: - case WarningSeverity.Blocked: - return '$neutral2' - case WarningSeverity.Low: - case WarningSeverity.None: - default: - return undefined - } -} diff --git a/packages/uniswap/src/constants/routing.ts b/packages/uniswap/src/constants/routing.ts index 8f3754ba2a9..ee46a590468 100644 --- a/packages/uniswap/src/constants/routing.ts +++ b/packages/uniswap/src/constants/routing.ts @@ -50,6 +50,7 @@ import { } from 'uniswap/src/constants/tokens' import { SafetyLevel } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' import { CurrencyInfo } from 'uniswap/src/features/dataApi/types' +import { buildCurrencyInfo } from 'uniswap/src/features/dataApi/utils' import { UniverseChainId } from 'uniswap/src/types/chains' import { isSameAddress } from 'utilities/src/addresses' @@ -68,14 +69,14 @@ export const COMMON_BASES: ChainCurrencyList = { USDT, WBTC, WRAPPED_NATIVE_CURRENCY[UniverseChainId.Mainnet] as Token, - ].map(buildCurrencyInfo), + ].map(buildPartialCurrencyInfo), [UniverseChainId.Sepolia]: [ nativeOnChain(UniverseChainId.Sepolia), WRAPPED_NATIVE_CURRENCY[UniverseChainId.Sepolia] as Token, USDC_SEPOLIA, UNI[UniverseChainId.Sepolia], - ].map(buildCurrencyInfo), + ].map(buildPartialCurrencyInfo), [UniverseChainId.ArbitrumOne]: [ nativeOnChain(UniverseChainId.ArbitrumOne), @@ -85,7 +86,7 @@ export const COMMON_BASES: ChainCurrencyList = { USDT_ARBITRUM_ONE, WBTC_ARBITRUM_ONE, WRAPPED_NATIVE_CURRENCY[UniverseChainId.ArbitrumOne] as Token, - ].map(buildCurrencyInfo), + ].map(buildPartialCurrencyInfo), [UniverseChainId.Optimism]: [ nativeOnChain(UniverseChainId.Optimism), @@ -95,18 +96,18 @@ export const COMMON_BASES: ChainCurrencyList = { USDT_OPTIMISM, WBTC_OPTIMISM, WETH9[UniverseChainId.Optimism] as Token, - ].map(buildCurrencyInfo), + ].map(buildPartialCurrencyInfo), [UniverseChainId.Base]: [ nativeOnChain(UniverseChainId.Base), WRAPPED_NATIVE_CURRENCY[UniverseChainId.Base] as Token, USDC_BASE, - ].map(buildCurrencyInfo), + ].map(buildPartialCurrencyInfo), [UniverseChainId.Blast]: [ nativeOnChain(UniverseChainId.Blast), WRAPPED_NATIVE_CURRENCY[UniverseChainId.Blast] as Token, - ].map(buildCurrencyInfo), + ].map(buildPartialCurrencyInfo), [UniverseChainId.Polygon]: [ nativeOnChain(UniverseChainId.Polygon), @@ -115,7 +116,7 @@ export const COMMON_BASES: ChainCurrencyList = { DAI_POLYGON, USDT_POLYGON, WBTC_POLYGON, - ].map(buildCurrencyInfo), + ].map(buildPartialCurrencyInfo), [UniverseChainId.Celo]: [ nativeOnChain(UniverseChainId.Celo), @@ -124,7 +125,7 @@ export const COMMON_BASES: ChainCurrencyList = { PORTAL_ETH_CELO, USDC_CELO, WBTC_CELO, - ].map(buildCurrencyInfo), + ].map(buildPartialCurrencyInfo), [UniverseChainId.Bnb]: [ nativeOnChain(UniverseChainId.Bnb), @@ -134,7 +135,7 @@ export const COMMON_BASES: ChainCurrencyList = { ETH_BSC, BTC_BSC, BUSD_BSC, - ].map(buildCurrencyInfo), + ].map(buildPartialCurrencyInfo), [UniverseChainId.Avalanche]: [ nativeOnChain(UniverseChainId.Avalanche), @@ -142,32 +143,32 @@ export const COMMON_BASES: ChainCurrencyList = { USDC_AVALANCHE, USDT_AVALANCHE, WETH_AVALANCHE, - ].map(buildCurrencyInfo), + ].map(buildPartialCurrencyInfo), [UniverseChainId.WorldChain]: [ nativeOnChain(UniverseChainId.WorldChain), WRAPPED_NATIVE_CURRENCY[UniverseChainId.WorldChain] as Token, USDC_WORLD_CHAIN, - ].map(buildCurrencyInfo), + ].map(buildPartialCurrencyInfo), [UniverseChainId.Zora]: [ nativeOnChain(UniverseChainId.Zora), WRAPPED_NATIVE_CURRENCY[UniverseChainId.Zora] as Token, USDC_ZORA, - ].map(buildCurrencyInfo), + ].map(buildPartialCurrencyInfo), [UniverseChainId.Zksync]: [ nativeOnChain(UniverseChainId.Zksync), WRAPPED_NATIVE_CURRENCY[UniverseChainId.Zksync] as Token, USDC_ZKSYNC, - ].map(buildCurrencyInfo), + ].map(buildPartialCurrencyInfo), [UniverseChainId.AstrochainSepolia]: [ nativeOnChain(UniverseChainId.AstrochainSepolia), WRAPPED_NATIVE_CURRENCY[UniverseChainId.AstrochainSepolia] as Token, // TODO(WEB-5160): re-add usdc sepolia // USDC_ASTROCHAIN_SEPOLIA, - ].map(buildCurrencyInfo), + ].map(buildPartialCurrencyInfo), } function getNativeLogoURI(chainId: UniverseChainId = UniverseChainId.Mainnet): ImageSourcePropType { @@ -194,15 +195,15 @@ function getTokenLogoURI(chainId: UniverseChainId, address: string): ImageSource : undefined } -export function buildCurrencyInfo(commonBase: Currency): CurrencyInfo { +export function buildPartialCurrencyInfo(commonBase: Currency): CurrencyInfo { const logoUrl = commonBase.isNative ? getNativeLogoURI(commonBase.chainId) : getTokenLogoURI(commonBase.chainId, commonBase.address) - return { + return buildCurrencyInfo({ currency: commonBase, logoUrl, safetyLevel: SafetyLevel.Verified, isSpam: false, - } as CurrencyInfo + } as CurrencyInfo) } diff --git a/packages/uniswap/src/constants/urls.ts b/packages/uniswap/src/constants/urls.ts index 2800d6654ef..266bfc80522 100644 --- a/packages/uniswap/src/constants/urls.ts +++ b/packages/uniswap/src/constants/urls.ts @@ -17,6 +17,7 @@ export const UNISWAP_WEB_HOSTNAME = 'app.uniswap.org' export const UNISWAP_WEB_URL = `https://${UNISWAP_WEB_HOSTNAME}` export const UNISWAP_APP_URL = 'https://uniswap.org/app' +export const UNISWAP_MOBILE_REDIRECT_URL = 'https://uniswap.org/mobile-redirect' const helpUrl = 'https://support.uniswap.org/hc/en-us' @@ -43,7 +44,9 @@ export const uniswapUrls = { transferCryptoHelp: `${helpUrl}/articles/27103878635661-How-to-transfer-crypto-from-a-Robinhood-or-Coinbase-account-to-the-Uniswap-Wallet`, moonpayRegionalAvailability: `${helpUrl}/articles/11306664890381-Why-isn-t-MoonPay-available-in-my-region-`, networkFeeInfo: `${helpUrl}/articles/8370337377805-What-is-a-network-fee-`, + positionsLearnMore: `${helpUrl}/sections/30998264709645`, priceImpact: `${helpUrl}/articles/8671539602317-What-is-Price-Impact`, + providingLiquidityInfo: `${helpUrl}/articles/30998269400333`, recoveryPhraseHowToImport: `${helpUrl}/articles/11380692567949-How-to-import-a-recovery-phrase-into-the-Uniswap-Wallet`, recoveryPhraseHowToFind: `${helpUrl}/articles/11306360177677-How-to-find-my-recovery-phrase-in-the-Uniswap-Wallet`, recoveryPhraseForgotten: `${helpUrl}/articles/11306367118349`, @@ -58,7 +61,9 @@ export const uniswapUrls = { uniswapXFailure: `${helpUrl}/articles/17515489874189-Why-can-my-swap-not-be-filled-`, unitagClaimPeriod: `${helpUrl}/articles/24009960408589`, unsupportedTokenPolicy: `${helpUrl}/articles/18783694078989-Unsupported-Token-Policy`, + v4HooksInfo: `${helpUrl}/articles/30998263256717`, walletHelp: `${helpUrl}/categories/11301970439565-Uniswap-Wallet`, + walletSecurityMeasures: `${helpUrl}/articles/28278904584077-Uniswap-Wallet-Security-Measures`, wethExplainer: `${helpUrl}/articles/16015852009997-Why-do-ETH-swaps-involve-converting-to-WETH`, }, termsOfServiceUrl: 'https://uniswap.org/terms-of-service', @@ -98,11 +103,12 @@ export const uniswapUrls = { decreaseLp: '/v1/lp/decrease', claimLpFees: '/v1/lp/claim', lpApproval: '/v1/lp/approve', + migrate: '/v1/lp/migrate', }, // App and Redirect URL's appBaseUrl: UNISWAP_APP_URL, - redirectUrlBase: isAndroid ? UNISWAP_WEB_URL : UNISWAP_APP_URL, + redirectUrlBase: UNISWAP_MOBILE_REDIRECT_URL, requestOriginUrl: UNISWAP_WEB_URL, // Web Interface Urls diff --git a/packages/uniswap/src/data/apiClients/simpleHashApi/SimpleHashApiClient.ts b/packages/uniswap/src/data/apiClients/simpleHashApi/SimpleHashApiClient.ts index 8b4fa6f8987..dd0bbdd3e1e 100644 --- a/packages/uniswap/src/data/apiClients/simpleHashApi/SimpleHashApiClient.ts +++ b/packages/uniswap/src/data/apiClients/simpleHashApi/SimpleHashApiClient.ts @@ -11,26 +11,6 @@ const SimpleHashApiClient = createApiClient({ }, }) -export type SimpleHashNftsRequest = { - contractAddress: string - tokenId: string -} - -export type SimpleHashNftsResponse = { - previews: { - image_small_url: string | null - image_medium_url: string | null - image_large_url: string | null - image_opengraph_url: string | null - blurhash: string | null - predominant_color: string | null - } | null -} - -export async function fetchNft({ contractAddress, tokenId }: SimpleHashNftsRequest): Promise { - return await SimpleHashApiClient.get(`/nfts/ethereum/${contractAddress}/${tokenId}`) -} - export type SimpleHashResponse = { message: string success: boolean diff --git a/packages/uniswap/src/data/apiClients/simpleHashApi/useSimpleHashNft.ts b/packages/uniswap/src/data/apiClients/simpleHashApi/useSimpleHashNft.ts deleted file mode 100644 index 71229298e18..00000000000 --- a/packages/uniswap/src/data/apiClients/simpleHashApi/useSimpleHashNft.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { UseQueryResult, skipToken, useQuery } from '@tanstack/react-query' -import { - SIMPLE_HASH_API_CACHE_KEY, - SimpleHashNftsRequest, - SimpleHashNftsResponse, - fetchNft, -} from 'uniswap/src/data/apiClients/simpleHashApi/SimpleHashApiClient' -import { UseQueryApiHelperHookArgs } from 'uniswap/src/data/apiClients/types' - -export function useSimpleHashNft({ - params, - ...rest -}: UseQueryApiHelperHookArgs): UseQueryResult { - const queryKey = [SIMPLE_HASH_API_CACHE_KEY, '/nfts/ethereum', params] - - return useQuery({ - queryKey, - queryFn: params ? async (): ReturnType => await fetchNft(params) : skipToken, - ...rest, - }) -} diff --git a/packages/uniswap/src/data/apiClients/tradingApi/TradingApiClient.ts b/packages/uniswap/src/data/apiClients/tradingApi/TradingApiClient.ts index 1cb10234b8c..848c11d7232 100644 --- a/packages/uniswap/src/data/apiClients/tradingApi/TradingApiClient.ts +++ b/packages/uniswap/src/data/apiClients/tradingApi/TradingApiClient.ts @@ -26,6 +26,8 @@ import { IncreaseLPPositionResponse, IndicativeQuoteRequest, IndicativeQuoteResponse, + MigrateLPPositionRequest, + MigrateLPPositionResponse, OrderRequest, OrderResponse, PriorityQuote, @@ -154,11 +156,15 @@ export async function increaseLpPosition(params: IncreaseLPPositionRequest): Pro }), }) } -export async function checkLpApproval(params: CheckApprovalLPRequest): Promise { +export async function checkLpApproval( + params: CheckApprovalLPRequest, + headers?: Record, +): Promise { return await TradingApiClient.post(uniswapUrls.tradingApiPaths.lpApproval, { body: JSON.stringify({ ...params, }), + headers, }) } @@ -178,3 +184,11 @@ export async function fetchSwaps(params: { txHashes: TransactionHash[]; chainId: }, }) } + +export async function migrateLpPosition(params: MigrateLPPositionRequest): Promise { + return await TradingApiClient.post(uniswapUrls.tradingApiPaths.migrate, { + body: JSON.stringify({ + ...params, + }), + }) +} diff --git a/packages/uniswap/src/data/apiClients/tradingApi/useCheckLpApprovalQuery.ts b/packages/uniswap/src/data/apiClients/tradingApi/useCheckLpApprovalQuery.ts index facebfb4b41..d3e695be6a6 100644 --- a/packages/uniswap/src/data/apiClients/tradingApi/useCheckLpApprovalQuery.ts +++ b/packages/uniswap/src/data/apiClients/tradingApi/useCheckLpApprovalQuery.ts @@ -6,16 +6,18 @@ import { CheckApprovalLPRequest, CheckApprovalLPResponse } from 'uniswap/src/dat export function useCheckLpApprovalQuery({ params, + headers, ...rest -}: UseQueryApiHelperHookArgs< - CheckApprovalLPRequest, - CheckApprovalLPResponse ->): UseQueryResult { +}: UseQueryApiHelperHookArgs & { + headers?: Record +}): UseQueryResult { const queryKey = [TRADING_API_CACHE_KEY, uniswapUrls.tradingApiPaths.lpApproval, params] return useQuery({ queryKey, - queryFn: params ? async (): ReturnType => await checkLpApproval(params) : skipToken, + queryFn: params + ? async (): ReturnType => await checkLpApproval(params, headers) + : skipToken, ...rest, }) } diff --git a/packages/uniswap/src/data/apiClients/tradingApi/useMigrateV3LpPositionCalldataQuery.ts b/packages/uniswap/src/data/apiClients/tradingApi/useMigrateV3LpPositionCalldataQuery.ts new file mode 100644 index 00000000000..095ecf6bea7 --- /dev/null +++ b/packages/uniswap/src/data/apiClients/tradingApi/useMigrateV3LpPositionCalldataQuery.ts @@ -0,0 +1,21 @@ +import { UseQueryResult, skipToken, useQuery } from '@tanstack/react-query' +import { uniswapUrls } from 'uniswap/src/constants/urls' +import { TRADING_API_CACHE_KEY, migrateLpPosition } from 'uniswap/src/data/apiClients/tradingApi/TradingApiClient' +import { UseQueryApiHelperHookArgs } from 'uniswap/src/data/apiClients/types' +import { MigrateLPPositionRequest, MigrateLPPositionResponse } from 'uniswap/src/data/tradingApi/__generated__' + +export function useMigrateV3LpPositionCalldataQuery({ + params, + ...rest +}: UseQueryApiHelperHookArgs< + MigrateLPPositionRequest, + MigrateLPPositionResponse +>): UseQueryResult { + const queryKey = [TRADING_API_CACHE_KEY, uniswapUrls.tradingApiPaths.migrate, params] + + return useQuery({ + queryKey, + queryFn: params ? async (): ReturnType => await migrateLpPosition(params) : skipToken, + ...rest, + }) +} diff --git a/packages/uniswap/src/data/apiClients/unitagsApi/UnitagsApiClient.ts b/packages/uniswap/src/data/apiClients/unitagsApi/UnitagsApiClient.ts index 0e776ea97a2..0685f268034 100644 --- a/packages/uniswap/src/data/apiClients/unitagsApi/UnitagsApiClient.ts +++ b/packages/uniswap/src/data/apiClients/unitagsApi/UnitagsApiClient.ts @@ -3,6 +3,8 @@ import { createApiClient } from 'uniswap/src/data/apiClients/createApiClient' import { UnitagAddressRequest, UnitagAddressResponse, + UnitagAddressesRequest, + UnitagAddressesResponse, UnitagClaimEligibilityRequest, UnitagClaimEligibilityResponse, UnitagUsernameRequest, @@ -23,6 +25,12 @@ export async function fetchAddress(params: UnitagAddressRequest): Promise('/address', { params }) } +export async function fetchAddresses({ addresses }: UnitagAddressesRequest): Promise { + return await UnitagsApiClient.get( + `/addresses?addresses=${encodeURIComponent(addresses.join(','))}`, + ) +} + export async function fetchClaimEligibility( params: UnitagClaimEligibilityRequest, ): Promise { diff --git a/packages/uniswap/src/data/apiClients/unitagsApi/useUnitagsAddressQuery.ts b/packages/uniswap/src/data/apiClients/unitagsApi/useUnitagsAddressQuery.ts index e75b5c19750..112e9841dfa 100644 --- a/packages/uniswap/src/data/apiClients/unitagsApi/useUnitagsAddressQuery.ts +++ b/packages/uniswap/src/data/apiClients/unitagsApi/useUnitagsAddressQuery.ts @@ -1,7 +1,16 @@ import { UseQueryResult, skipToken, useQuery } from '@tanstack/react-query' import { UseQueryApiHelperHookArgs } from 'uniswap/src/data/apiClients/types' -import { UNITAGS_API_CACHE_KEY, fetchAddress } from 'uniswap/src/data/apiClients/unitagsApi/UnitagsApiClient' -import { UnitagAddressRequest, UnitagAddressResponse } from 'uniswap/src/features/unitags/types' +import { + UNITAGS_API_CACHE_KEY, + fetchAddress, + fetchAddresses, +} from 'uniswap/src/data/apiClients/unitagsApi/UnitagsApiClient' +import { + UnitagAddressRequest, + UnitagAddressResponse, + UnitagAddressesRequest, + UnitagAddressesResponse, +} from 'uniswap/src/features/unitags/types' import { MAX_REACT_QUERY_CACHE_TIME_MS, ONE_MINUTE_MS } from 'utilities/src/time/time' export function useUnitagsAddressQuery({ @@ -18,3 +27,21 @@ export function useUnitagsAddressQuery({ ...rest, }) } + +export function useUnitagsAddressesQuery({ + params, + ...rest +}: UseQueryApiHelperHookArgs< + UnitagAddressesRequest, + UnitagAddressesResponse +>): UseQueryResult { + const queryKey = [UNITAGS_API_CACHE_KEY, 'addresses', params] + + return useQuery({ + queryKey, + queryFn: params ? async (): ReturnType => await fetchAddresses(params) : skipToken, + staleTime: ONE_MINUTE_MS, + gcTime: MAX_REACT_QUERY_CACHE_TIME_MS, + ...rest, + }) +} diff --git a/packages/uniswap/src/data/graphql/uniswap-data-api/queries.graphql b/packages/uniswap/src/data/graphql/uniswap-data-api/queries.graphql index cc715c8fd87..5512e544159 100644 --- a/packages/uniswap/src/data/graphql/uniswap-data-api/queries.graphql +++ b/packages/uniswap/src/data/graphql/uniswap-data-api/queries.graphql @@ -278,6 +278,10 @@ query NftsTab( height } } + thumbnail { + id + url + } name tokenId description @@ -298,6 +302,27 @@ query NftsTab( } } +# We use this fragment to optimize how we render each row in the Tokens tab. +# We should keep it small, only include the fields we need to render that row, +# and avoid including fields that might change too often. +fragment TokenBalanceMainParts on TokenBalance { + ...TokenBalanceQuantityParts + denominatedValue { + currency + value + } + tokenProjectMarket { + relativeChange24: pricePercentChange(duration: DAY) { + value + } + } +} + +fragment TokenBalanceQuantityParts on TokenBalance { + id + quantity +} + query PortfolioBalances( $ownerAddress: String! $valueModifiers: [PortfolioValueModifier!] @@ -324,14 +349,8 @@ query PortfolioBalances( # Individual portfolio token balances tokenBalances { - id - quantity + ...TokenBalanceMainParts isHidden - denominatedValue { - id - currency - value - } token { id address @@ -346,6 +365,7 @@ query PortfolioBalances( logoUrl name safetyLevel + spamCode } feeData { buyFeeBps @@ -356,11 +376,6 @@ query PortfolioBalances( attackTypes } } - tokenProjectMarket { - relativeChange24: pricePercentChange(duration: DAY) { - value - } - } } } } @@ -588,6 +603,14 @@ query TokenProjects($contracts: [ContractInput!]!) { address decimals symbol + feeData { + buyFeeBps + sellFeeBps + } + protectionInfo { + result + attackTypes + } } } } @@ -947,6 +970,10 @@ query SearchTokens($searchQuery: String!, $chains: [Chain!]!) { logoUrl safetyLevel } + feeData { + buyFeeBps + sellFeeBps + } protectionInfo { result attackTypes @@ -979,6 +1006,10 @@ query ExploreSearch( result attackTypes } + feeData { + buyFeeBps + sellFeeBps + } } nftCollections(filter: $nftCollectionsFilter, first: 4) { edges { diff --git a/packages/uniswap/src/data/graphql/uniswap-data-api/web/RecentlySearchedAssets.graphql b/packages/uniswap/src/data/graphql/uniswap-data-api/web/RecentlySearchedAssets.graphql index 972f691e1c2..fb2454733de 100644 --- a/packages/uniswap/src/data/graphql/uniswap-data-api/web/RecentlySearchedAssets.graphql +++ b/packages/uniswap/src/data/graphql/uniswap-data-api/web/RecentlySearchedAssets.graphql @@ -32,6 +32,14 @@ query RecentlySearchedAssets( standard address symbol + feeData { + buyFeeBps + sellFeeBps + } + protectionInfo { + attackTypes + result + } market(currency: USD) { id price { diff --git a/packages/uniswap/src/data/graphql/uniswap-data-api/web/SimpleToken.graphql b/packages/uniswap/src/data/graphql/uniswap-data-api/web/SimpleToken.graphql index f6f9301ed8d..2ed43374f65 100644 --- a/packages/uniswap/src/data/graphql/uniswap-data-api/web/SimpleToken.graphql +++ b/packages/uniswap/src/data/graphql/uniswap-data-api/web/SimpleToken.graphql @@ -17,4 +17,8 @@ fragment SimpleTokenDetails on Token { buyFeeBps sellFeeBps } + protectionInfo { + attackTypes + result + } } diff --git a/packages/uniswap/src/data/rest/getPair.ts b/packages/uniswap/src/data/rest/getPair.ts index 4ca695cc109..9ef90a52455 100644 --- a/packages/uniswap/src/data/rest/getPair.ts +++ b/packages/uniswap/src/data/rest/getPair.ts @@ -5,11 +5,11 @@ import { useQuery } from '@connectrpc/connect-query' import { UseQueryResult } from '@tanstack/react-query' import { getPair } from '@uniswap/client-pools/dist/pools/v1/api-PoolsService_connectquery' import { GetPairRequest, GetPairResponse } from '@uniswap/client-pools/dist/pools/v1/api_pb' -import { getPositionsTestTransport } from 'uniswap/src/data/rest/getPositions' +import { uniswapGetTransport } from 'uniswap/src/data/rest/base' export function useGetPair( input?: PartialMessage, enabled = true, ): UseQueryResult { - return useQuery(getPair, input, { transport: getPositionsTestTransport, enabled }) + return useQuery(getPair, input, { transport: uniswapGetTransport, enabled, retry: false }) } diff --git a/packages/uniswap/src/data/rest/getPools.ts b/packages/uniswap/src/data/rest/getPools.ts index 3e233199605..228e7b5570d 100644 --- a/packages/uniswap/src/data/rest/getPools.ts +++ b/packages/uniswap/src/data/rest/getPools.ts @@ -5,11 +5,11 @@ import { useQuery } from '@connectrpc/connect-query' import { UseQueryResult } from '@tanstack/react-query' import { listPools } from '@uniswap/client-pools/dist/pools/v1/api-PoolsService_connectquery' import { ListPoolsRequest, ListPoolsResponse } from '@uniswap/client-pools/dist/pools/v1/api_pb' -import { getPositionsTestTransport } from 'uniswap/src/data/rest/getPositions' +import { uniswapGetTransport } from 'uniswap/src/data/rest/base' export function useGetPoolsByTokens( input?: PartialMessage, enabled = true, ): UseQueryResult { - return useQuery(listPools, input, { transport: getPositionsTestTransport, enabled }) + return useQuery(listPools, input, { transport: uniswapGetTransport, enabled }) } diff --git a/packages/uniswap/src/data/rest/getPosition.ts b/packages/uniswap/src/data/rest/getPosition.ts index 96207d3c30f..1b8e9a69d16 100644 --- a/packages/uniswap/src/data/rest/getPosition.ts +++ b/packages/uniswap/src/data/rest/getPosition.ts @@ -5,10 +5,10 @@ import { useQuery } from '@connectrpc/connect-query' import { UseQueryResult } from '@tanstack/react-query' import { getPosition } from '@uniswap/client-pools/dist/pools/v1/api-PoolsService_connectquery' import { GetPositionRequest, GetPositionResponse } from '@uniswap/client-pools/dist/pools/v1/api_pb' -import { getPositionsTestTransport } from 'uniswap/src/data/rest/getPositions' +import { uniswapGetTransport } from 'uniswap/src/data/rest/base' export function useGetPositionQuery( input?: PartialMessage, ): UseQueryResult { - return useQuery(getPosition, input, { transport: getPositionsTestTransport, enabled: !!input }) + return useQuery(getPosition, input, { transport: uniswapGetTransport, enabled: !!input }) } diff --git a/packages/uniswap/src/data/rest/getPositions.ts b/packages/uniswap/src/data/rest/getPositions.ts index 20d47f4b2c0..017e6b136da 100644 --- a/packages/uniswap/src/data/rest/getPositions.ts +++ b/packages/uniswap/src/data/rest/getPositions.ts @@ -2,17 +2,18 @@ import { PartialMessage } from '@bufbuild/protobuf' import { ConnectError } from '@connectrpc/connect' import { useQuery } from '@connectrpc/connect-query' -import { createConnectTransport } from '@connectrpc/connect-web' -import { UseQueryResult } from '@tanstack/react-query' +import { UseQueryResult, keepPreviousData } from '@tanstack/react-query' import { listPositions } from '@uniswap/client-pools/dist/pools/v1/api-PoolsService_connectquery' import { ListPositionsRequest, ListPositionsResponse } from '@uniswap/client-pools/dist/pools/v1/api_pb' - -export const getPositionsTestTransport = createConnectTransport({ - baseUrl: '', // TODO: replace with the prod url and update in csp.json as well -}) +import { uniswapGetTransport } from 'uniswap/src/data/rest/base' export function useGetPositionsQuery( input?: PartialMessage, + disabled?: boolean, ): UseQueryResult { - return useQuery(listPositions, input, { transport: getPositionsTestTransport, enabled: !!input }) + return useQuery(listPositions, input, { + transport: uniswapGetTransport, + enabled: !!input && !disabled, + placeholderData: keepPreviousData, + }) } diff --git a/packages/uniswap/src/data/tradingApi/api.json b/packages/uniswap/src/data/tradingApi/api.json index 528ce7f8ab0..58ae1472b72 100644 --- a/packages/uniswap/src/data/tradingApi/api.json +++ b/packages/uniswap/src/data/tradingApi/api.json @@ -1 +1 @@ -{"openapi":"3.0.0","servers":[{"description":"Uniswap trading APIs Beta","url":"https://beta.trade-api.gateway.uniswap.org/v1"},{"description":"Uniswap trading APIs","url":"https://trade-api.gateway.uniswap.org/v1"}],"info":{"version":"1.0.0","title":"Token Trading","description":"Uniswap trading APIs for fungible tokens."},"paths":{"/check_approval":{"post":{"tags":["Approval"],"summary":"Check if token approval is required","description":"Checks if the swapper has the required approval. If the swapper does not have the required approval, then the response will include the transaction to approve the token. If the swapper has the required approval, then the response will be empty. If the parameter `includeGasInfo` is set to `true`, then the response will include the gas fee for the approval transaction.","operationId":"check_approval","security":[{"apiKey":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApprovalRequest"}}}},"responses":{"200":{"$ref":"#/components/responses/ApprovalSuccess200"},"400":{"$ref":"#/components/responses/BadRequest400"},"401":{"$ref":"#/components/responses/ApprovalUnauthorized401"},"404":{"$ref":"#/components/responses/ApprovalNotFound404"},"419":{"$ref":"#/components/responses/RateLimitedErr429"},"500":{"$ref":"#/components/responses/InternalErr500"},"504":{"$ref":"#/components/responses/Timeout504"}}}},"/quote":{"post":{"tags":["Quote"],"summary":"Get a quote","description":"Get a quote according to the provided configuration. Optionally adds a fee to the quote according to the API key being used. The fee is **ALWAYS** taken from the output token. If there is a fee and the trade is `EXACT_INPUT`, then the output amount will **NOT** include the fee subtraction. For `EXACT_INPUT` swaps, use `portionBips` to calculate the fee from the quoted amount. If there is a fee and the trade is `EXACT_OUTPUT`, then the input amount will **NOT** include the fee addition to account for the fee. For `EXACT_OUTPUT` swaps, use `portionAmount` to get the fee. \n \n We also support Wrapping and Unwrapping of native tokens on their respective chains. Wrapping and Unwrapping only works for when `routingPreference` is `CLASSIC`, `BEST_PRICE`, or `BEST_PRICE_V2`. We do not support `UNISWAPX` or `UNISWAPX_V2` for these actions.","operationId":"aggregator_quote","security":[{"apiKey":[]}],"parameters":[{"$ref":"#/components/parameters/universalRouterVersionHeader"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuoteRequest"}}}},"responses":{"200":{"$ref":"#/components/responses/QuoteSuccess200"},"400":{"$ref":"#/components/responses/BadRequest400"},"401":{"$ref":"#/components/responses/Unauthorized401"},"404":{"$ref":"#/components/responses/QuoteNotFound404"},"419":{"$ref":"#/components/responses/RateLimitedErr429"},"500":{"$ref":"#/components/responses/InternalErr500"},"504":{"$ref":"#/components/responses/Timeout504"}}}},"/order":{"post":{"tags":["Order"],"summary":"Create a gasless order","description":"Submits a new gasless encoded order. The order will be validated and if valid, will be submitted to the filler network. The network will try to fill the order at the quoted `startAmount`, and if not, the amount will start decaying until the `endAmount` is reached. While the order is within `decayEndTime`, the `orderStatus` is `open`. If the order does not get filled after the `decayEndTime` has passed, that is reflected in the `expired` `orderStatus`. then The order will be filled at the best price possible. Once the order is filled, `orderStatus` becomes `filled`.","operationId":"post_order","security":[{"apiKey":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrderRequest"}}}},"responses":{"201":{"$ref":"#/components/responses/OrderSuccess201"},"400":{"$ref":"#/components/responses/BadRequest400"},"401":{"$ref":"#/components/responses/Unauthorized401"},"419":{"$ref":"#/components/responses/RateLimitedErr429"},"500":{"$ref":"#/components/responses/InternalErr500"},"504":{"$ref":"#/components/responses/Timeout504"}}}},"/orders":{"get":{"tags":["Order"],"summary":"Get gasless orders","description":"Retrieve gasless orders filtered by query param(s). Some fields on the order can be used as query param.","operationId":"get_order","security":[{"apiKey":[]}],"parameters":[{"$ref":"#/components/parameters/orderTypeParam"},{"$ref":"#/components/parameters/orderIdParam"},{"$ref":"#/components/parameters/orderIdsParam"},{"$ref":"#/components/parameters/limitParam"},{"$ref":"#/components/parameters/orderStatusParam"},{"$ref":"#/components/parameters/swapperParam"},{"$ref":"#/components/parameters/sortKeyParam"},{"$ref":"#/components/parameters/sortParam"},{"$ref":"#/components/parameters/fillerParam"},{"$ref":"#/components/parameters/cursorParam"}],"responses":{"200":{"$ref":"#/components/responses/OrdersSuccess200"},"400":{"$ref":"#/components/responses/OrdersBadRequest400"},"404":{"$ref":"#/components/responses/OrdersNotFound404"},"419":{"$ref":"#/components/responses/RateLimitedErr429"},"500":{"$ref":"#/components/responses/InternalErr500"},"504":{"$ref":"#/components/responses/Timeout504"}}}},"/swap":{"post":{"tags":["Swap"],"summary":"Create swap calldata","description":"Create the calldata for a swap transaction (including wrap/unwrap) against the Uniswap Protocols. If the `quote` parameter includes the fee parameters, then the calldata will include the fee disbursement. The gas estimates will be **more precise** when the the response calldata would be valid if submitted on-chain.","operationId":"create_swap_transaction","security":[{"apiKey":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSwapRequest"}}}},"parameters":[{"$ref":"#/components/parameters/universalRouterVersionHeader"}],"responses":{"200":{"$ref":"#/components/responses/CreateSwapSuccess200"},"400":{"$ref":"#/components/responses/BadRequest400"},"401":{"$ref":"#/components/responses/SwapUnauthorized401"},"404":{"$ref":"#/components/responses/SwapNotFound404"},"419":{"$ref":"#/components/responses/RateLimitedErr429"},"500":{"$ref":"#/components/responses/InternalErr500"},"504":{"$ref":"#/components/responses/Timeout504"}}}},"/swaps":{"get":{"tags":["Swap"],"summary":"Get swaps status","description":"Get the status of a swap or bridge transactions.","operationId":"get_swaps","security":[{"apiKey":[]}],"parameters":[{"$ref":"#/components/parameters/transactionHashesParam"},{"$ref":"#/components/parameters/chainIdParam"}],"responses":{"200":{"$ref":"#/components/responses/GetSwapsSuccess200"},"400":{"$ref":"#/components/responses/BadRequest400"},"404":{"$ref":"#/components/responses/SwapNotFound404"},"419":{"$ref":"#/components/responses/RateLimitedErr429"},"500":{"$ref":"#/components/responses/InternalErr500"},"504":{"$ref":"#/components/responses/Timeout504"}}}},"/indicative_quote":{"post":{"tags":["IndicativeQuote"],"summary":"Get an indicative quote","description":"Get an indicative quote according to the provided configuration. The quote will not include a fee.","operationId":"indicative_quote","security":[{"apiKey":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IndicativeQuoteRequest"}}}},"responses":{"200":{"$ref":"#/components/responses/IndicativeQuoteSuccess200"},"400":{"$ref":"#/components/responses/BadRequest400"},"404":{"$ref":"#/components/responses/QuoteNotFound404"},"419":{"$ref":"#/components/responses/RateLimitedErr429"},"500":{"$ref":"#/components/responses/InternalErr500"},"504":{"$ref":"#/components/responses/Timeout504"}}}},"/send":{"post":{"tags":["Send"],"summary":"Create send calldata","description":"Create the calldata for a send transaction.","operationId":"create_send","security":[{"apiKey":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSendRequest"}}}},"responses":{"200":{"$ref":"#/components/responses/CreateSendSuccess200"},"400":{"$ref":"#/components/responses/BadRequest400"},"401":{"$ref":"#/components/responses/Unauthorized401"},"404":{"$ref":"#/components/responses/SendNotFound404"},"429":{"$ref":"#/components/responses/RateLimitedErr429"},"500":{"$ref":"#/components/responses/InternalErr500"},"504":{"$ref":"#/components/responses/Timeout504"}}}},"/swappable_tokens":{"get":{"tags":["SwappableTokens"],"summary":"Get swappable tokens","description":"Get the swappable tokens for the given configuration. Either tokenIn (with tokenInChainId or (tokenInChainId and tokenOutChainId)) or tokenOut (with tokenOutChainId or (tokenOutChainId and tokenInChainId)) must be provided but not both.","operationId":"get_swappable_tokens","security":[{"apiKey":[]}],"parameters":[{"$ref":"#/components/parameters/tokenInParam"},{"$ref":"#/components/parameters/tokenOutParam"},{"$ref":"#/components/parameters/bridgeTokenInChainIdParam"},{"$ref":"#/components/parameters/bridgeTokenOutChainIdParam"}],"responses":{"200":{"$ref":"#/components/responses/GetSwappableTokensSuccess200"},"400":{"$ref":"#/components/responses/BadRequest400"},"401":{"$ref":"#/components/responses/Unauthorized401"},"404":{"$ref":"#/components/responses/QuoteNotFound404"},"429":{"$ref":"#/components/responses/RateLimitedErr429"},"500":{"$ref":"#/components/responses/InternalErr500"},"504":{"$ref":"#/components/responses/Timeout504"}}}},"/limit_order_quote":{"post":{"tags":["LimitOrderQuote"],"summary":"Get a limit order quote","description":"Get a quote for a limit order according to the provided configuration.","operationId":"get_limit_order_quote","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LimitOrderQuoteRequest"}}}},"responses":{"200":{"$ref":"#/components/responses/LimitOrderQuoteSuccess200"},"400":{"$ref":"#/components/responses/BadRequest400"},"401":{"$ref":"#/components/responses/Unauthorized401"},"404":{"$ref":"#/components/responses/QuoteNotFound404"},"419":{"$ref":"#/components/responses/RateLimitedErr429"},"500":{"$ref":"#/components/responses/InternalErr500"},"504":{"$ref":"#/components/responses/Timeout504"}}}},"/lp/approve":{"post":{"tags":["Liquidity"],"summary":"Check if tokens and permits need to be approved to add liquidity","description":"Checks if the wallet address has the required approvals. If the wallet address does not have the required approval, then the response will include the transactions to approve the tokens. If the wallet address has the required approval, then the response will be empty for the corresponding tokens. If the parameter `simulateTransaction` is set to `true`, then the response will include the gas fees for the approval transactions.","operationId":"check_approval_lp","security":[{"apiKey":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckApprovalLPRequest"}}}},"responses":{"200":{"$ref":"#/components/responses/CheckApprovalLPSuccess200"},"400":{"$ref":"#/components/responses/BadRequest400"},"401":{"$ref":"#/components/responses/ApprovalUnauthorized401"},"404":{"$ref":"#/components/responses/ApprovalNotFound404"},"419":{"$ref":"#/components/responses/RateLimitedErr429"},"500":{"$ref":"#/components/responses/InternalErr500"},"504":{"$ref":"#/components/responses/Timeout504"}}}},"/lp/create":{"post":{"tags":["Liquidity"],"summary":"Create pool and position calldata","description":"Create pool and position calldata. If the pool is not yet created, then the response will include the transaction to create the new pool with the initial price. If the pool is already created, then the response will not have the transaction to create the pool. The response will also have the transaction to create the position for the corresponding pool. If the parameter `simulateTransaction` is set to `true`, then the response will include the gas fees for the creation transactions.","operationId":"create_lp_position","security":[{"apiKey":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateLPPositionRequest"}}}},"responses":{"200":{"$ref":"#/components/responses/CreateLPPositionSuccess200"},"400":{"$ref":"#/components/responses/BadRequest400"},"401":{"$ref":"#/components/responses/ApprovalUnauthorized401"},"404":{"$ref":"#/components/responses/LPNotFound404"},"419":{"$ref":"#/components/responses/RateLimitedErr429"},"500":{"$ref":"#/components/responses/InternalErr500"},"504":{"$ref":"#/components/responses/Timeout504"}}}},"/lp/increase":{"post":{"tags":["Liquidity"],"summary":"Increase LP position calldata","description":"The response will also have the transaction to increase the position for the corresponding pool. If the parameter `simulateTransaction` is set to `true`, then the response will include the gas fees for the increase transaction.","operationId":"increase_lp_position","security":[{"apiKey":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IncreaseLPPositionRequest"}}}},"responses":{"200":{"$ref":"#/components/responses/IncreaseLPPositionSuccess200"},"400":{"$ref":"#/components/responses/BadRequest400"},"401":{"$ref":"#/components/responses/ApprovalUnauthorized401"},"404":{"$ref":"#/components/responses/LPNotFound404"},"419":{"$ref":"#/components/responses/RateLimitedErr429"},"500":{"$ref":"#/components/responses/InternalErr500"},"504":{"$ref":"#/components/responses/Timeout504"}}}},"/lp/decrease":{"post":{"tags":["Liquidity"],"summary":"Decrease LP position calldata","description":"The response will also have the transaction to decrease the position for the corresponding pool. If the parameter `simulateTransaction` is set to `true`, then the response will include the gas fees for the decrease transaction.","operationId":"decrease_lp_position","security":[{"apiKey":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DecreaseLPPositionRequest"}}}},"responses":{"200":{"$ref":"#/components/responses/DecreaseLPPositionSuccess200"},"400":{"$ref":"#/components/responses/BadRequest400"},"401":{"$ref":"#/components/responses/ApprovalUnauthorized401"},"404":{"$ref":"#/components/responses/LPNotFound404"},"419":{"$ref":"#/components/responses/RateLimitedErr429"},"500":{"$ref":"#/components/responses/InternalErr500"},"504":{"$ref":"#/components/responses/Timeout504"}}}},"/lp/claim":{"post":{"tags":["Liquidity"],"summary":"Claim LP fees calldata","description":"The response will also have the transaction to claim the fees for an LP position for the corresponding pool. If the parameter `simulateTransaction` is set to `true`, then the response will include the gas fees for the claim transaction.","operationId":"claim_lp_fees","security":[{"apiKey":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClaimLPFeesRequest"}}}},"responses":{"200":{"$ref":"#/components/responses/ClaimLPFeesSuccess200"},"400":{"$ref":"#/components/responses/BadRequest400"},"401":{"$ref":"#/components/responses/ApprovalUnauthorized401"},"404":{"$ref":"#/components/responses/LPNotFound404"},"419":{"$ref":"#/components/responses/RateLimitedErr429"},"500":{"$ref":"#/components/responses/InternalErr500"},"504":{"$ref":"#/components/responses/Timeout504"}}}},"/lp/migrate":{"post":{"tags":["Liquidity"],"summary":"Migrate LP position calldata","description":"The response will also have the transaction to migrate the position for the corresponding pool. If the parameter `simulateTransaction` is set to `true`, then the response will include the gas fees for the migrate transaction.","operationId":"migrate_lp_position","security":[{"apiKey":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MigrateLPPositionRequest"}}}},"responses":{"200":{"$ref":"#/components/responses/MigrateLPPositionSuccess200"},"400":{"$ref":"#/components/responses/BadRequest400"},"401":{"$ref":"#/components/responses/ApprovalUnauthorized401"},"404":{"$ref":"#/components/responses/LPNotFound404"},"419":{"$ref":"#/components/responses/RateLimitedErr429"},"500":{"$ref":"#/components/responses/InternalErr500"},"504":{"$ref":"#/components/responses/Timeout504"}}}}},"components":{"responses":{"OrdersSuccess200":{"description":"The request orders matching the query parameters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetOrdersResponse"}}}},"OrderSuccess201":{"description":"Encoded order submitted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrderResponse"}}}},"QuoteSuccess200":{"description":"Quote request successful.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuoteResponse"}}}},"LimitOrderQuoteSuccess200":{"description":"Limit Order Quote request successful.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LimitOrderQuoteResponse"}}}},"CheckApprovalLPSuccess200":{"description":"Approve LP successful.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckApprovalLPResponse"}}}},"ApprovalSuccess200":{"description":"Check approval successful.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApprovalResponse"}}}},"CreateSendSuccess200":{"description":"Create send successful.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSendResponse"}}}},"CreateSwapSuccess200":{"description":"Create swap successful.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSwapResponse"}}}},"GetSwapsSuccess200":{"description":"Get swap successful.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetSwapsResponse"}}}},"GetSwappableTokensSuccess200":{"description":"Get swappable tokens successful.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetSwappableTokensResponse"}}}},"CreateLPPositionSuccess200":{"description":"Create LP Position successful.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateLPPositionResponse"}}}},"IncreaseLPPositionSuccess200":{"description":"Create LP Position successful.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/IncreaseLPPositionResponse"}}}},"DecreaseLPPositionSuccess200":{"description":"Decrease LP Position successful.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DecreaseLPPositionResponse"}}}},"ClaimLPFeesSuccess200":{"description":"Claim LP Fees successful.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClaimLPFeesResponse"}}}},"MigrateLPPositionSuccess200":{"description":"Migrate LP Position successful.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MigrateLPPositionResponse"}}}},"BadRequest400":{"description":"RequestValidationError, Bad Input","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Err400"}}}},"ApprovalUnauthorized401":{"description":"UnauthorizedError eg. Account is blocked.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Err401"}}}},"ApprovalNotFound404":{"description":"ResourceNotFound eg. Token allowance not found or Gas info not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Err404"}}}},"Unauthorized401":{"description":"UnauthorizedError eg. Account is blocked.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Err401"}}}},"QuoteNotFound404":{"description":"ResourceNotFound eg. No quotes available or Gas fee/price not available","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Err404"}}}},"SendNotFound404":{"description":"ResourceNotFound eg. Gas fee not available","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Err404"}}}},"SwapBadRequest400":{"description":"RequestValidationError, Bad Input","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Err400"}}}},"SwapUnauthorized401":{"description":"UnauthorizedError eg. Account is blocked or Fee is not enabled.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Err401"}}}},"SwapNotFound404":{"description":"ResourceNotFound eg. No quotes available or Gas fee/price not available","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Err404"}}}},"OrdersNotFound404":{"description":"Orders not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Err404"}}}},"LPNotFound404":{"description":"ResourceNotFound eg. Cant Find LP Position.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Err404"}}}},"OrdersBadRequest400":{"description":"RequestValidationError eg. Token allowance not valid or Insufficient Funds.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Err400"}}}},"RateLimitedErr429":{"description":"Ratelimited","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Err429"}}}},"InternalErr500":{"description":"Unexpected error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Err500"}}}},"Timeout504":{"description":"Request duration limit reached.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Err504"}}}},"IndicativeQuoteSuccess200":{"description":"Indicative quote request successful.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/IndicativeQuoteResponse"}}}}},"schemas":{"NullablePermit":{"allOf":[{"$ref":"#/components/schemas/Permit"},{"type":"object","nullable":true}]},"TokenAmount":{"type":"string"},"SwapStatus":{"type":"string","enum":["PENDING","SUCCESS","NOT_FOUND","FAILED","EXPIRED"]},"GetSwapsResponse":{"type":"object","properties":{"requestId":{"$ref":"#/components/schemas/RequestId"},"swaps":{"type":"array","items":{"type":"object","properties":{"swapType":{"$ref":"#/components/schemas/Routing"},"status":{"$ref":"#/components/schemas/SwapStatus"},"txHash":{"type":"string"},"swapId":{"type":"number"}}}}},"required":["requestId","status"]},"GetSwappableTokensResponse":{"type":"object","properties":{"requestId":{"$ref":"#/components/schemas/RequestId"},"tokens":{"type":"array","items":{"type":"object","properties":{"address":{"$ref":"#/components/schemas/Address"},"chainId":{"$ref":"#/components/schemas/ChainId"},"name":{"type":"string"},"symbol":{"type":"string"},"project":{"$ref":"#/components/schemas/TokenProject"},"isSpam":{"type":"boolean"},"decimals":{"type":"number"}},"required":["address","chainId","name","symbol","project","decimals"]}}},"required":["requestId","tokens"]},"CreateSwapRequest":{"type":"object","description":"The parameters **signature** and **permitData** should only be included if *permitData* was returned from **/quote**.","properties":{"quote":{"oneOf":[{"$ref":"#/components/schemas/ClassicQuote"},{"$ref":"#/components/schemas/WrapUnwrapQuote"},{"$ref":"#/components/schemas/BridgeQuote"}]},"signature":{"type":"string","description":"The signed permit."},"includeGasInfo":{"type":"boolean","default":false,"deprecated":true,"description":"Use `refreshGasPrice` instead."},"refreshGasPrice":{"type":"boolean","default":false,"description":"If true, the gas price will be re-fetched from the network."},"simulateTransaction":{"type":"boolean","default":false,"description":"If true, the transaction will be simulated. If the simulation results on an onchain error, endpoint will return an error."},"permitData":{"allOf":[{"$ref":"#/components/schemas/Permit"}]},"safetyMode":{"$ref":"#/components/schemas/SwapSafetyMode"},"deadline":{"type":"integer","description":"The deadline for the swap in unix timestamp format. If the deadline is not defined OR in the past then the default deadline is 30 minutes."},"urgency":{"$ref":"#/components/schemas/Urgency"}},"required":["quote"]},"CreateSendRequest":{"type":"object","properties":{"sender":{"$ref":"#/components/schemas/Address"},"recipient":{"$ref":"#/components/schemas/Address"},"token":{"$ref":"#/components/schemas/Address"},"amount":{"$ref":"#/components/schemas/TokenAmount"},"chainId":{"$ref":"#/components/schemas/ChainId"},"urgency":{"$ref":"#/components/schemas/Urgency"}},"required":["sender","recipient","token","amount"]},"UniversalRouterVersion":{"type":"string","enum":["1.2","2.0"],"default":"1.2"},"Address":{"type":"string","pattern":"^(0x)?[0-9a-fA-F]{40}$"},"Position":{"type":"object","properties":{"pool":{"$ref":"#/components/schemas/Pool"},"tickLower":{"type":"number"},"tickUpper":{"type":"number"}},"required":["pool"]},"Pool":{"type":"object","properties":{"token0":{"$ref":"#/components/schemas/Address"},"token1":{"$ref":"#/components/schemas/Address"},"fee":{"type":"number"},"tickSpacing":{"type":"number"},"hooks":{"$ref":"#/components/schemas/Address"}},"required":["token0","token1"]},"ClassicGasUseEstimateUSD":{"description":"The gas fee you would pay if you opted for a CLASSIC swap over a Uniswap X order in terms of USD.","type":"string"},"CreateSwapResponse":{"type":"object","properties":{"requestId":{"$ref":"#/components/schemas/RequestId"},"swap":{"$ref":"#/components/schemas/TransactionRequest"},"gasFee":{"type":"string"}},"required":["requestId","swap"]},"CreateSendResponse":{"type":"object","properties":{"requestId":{"$ref":"#/components/schemas/RequestId"},"send":{"$ref":"#/components/schemas/TransactionRequest"},"gasFee":{"type":"string"},"gasFeeUSD":{"type":"number"}},"required":["requestId","send"]},"QuoteResponse":{"type":"object","properties":{"requestId":{"$ref":"#/components/schemas/RequestId"},"quote":{"$ref":"#/components/schemas/Quote"},"routing":{"$ref":"#/components/schemas/Routing"},"permitData":{"$ref":"#/components/schemas/NullablePermit"}},"required":["routing","quote","permitData","requestId"]},"LimitOrderQuoteResponse":{"type":"object","properties":{"requestId":{"$ref":"#/components/schemas/RequestId"},"quote":{"$ref":"#/components/schemas/DutchQuote"},"routing":{"type":"string","enum":["LIMIT_ORDER"]},"permitData":{"$ref":"#/components/schemas/NullablePermit"}},"required":["routing","quote","permitData","requestId"]},"QuoteRequest":{"type":"object","properties":{"type":{"$ref":"#/components/schemas/TradeType"},"amount":{"type":"string"},"tokenInChainId":{"$ref":"#/components/schemas/ChainId"},"tokenOutChainId":{"$ref":"#/components/schemas/ChainId"},"tokenIn":{"type":"string"},"tokenOut":{"type":"string"},"swapper":{"$ref":"#/components/schemas/Address"},"slippageTolerance":{"description":"For **Classic** swaps, the slippage tolerance is the maximum amount the price can change between the time the transaction is submitted and the time it is executed. The slippage tolerance is represented as a percentage of the total value of the swap. \n\n Slippage tolerance works differently in **DutchLimit** swaps, it does not set a limit on the Spread in an order. See [here](https://uniswap-docs.readme.io/reference/faqs#why-do-the-uniswapx-quotes-have-more-slippage-than-the-tolerance-i-set) for more information. \n\n **NOTE**: slippage is in terms of trade type. If the trade type is `EXACT_INPUT`, then the slippage is in terms of the output token. If the trade type is `EXACT_OUTPUT`, then the slippage is in terms of the input token.","type":"number"},"autoSlippage":{"$ref":"#/components/schemas/AutoSlippage"},"routingPreference":{"$ref":"#/components/schemas/RoutingPreference"},"protocols":{"$ref":"#/components/schemas/Protocols"},"spreadOptimization":{"$ref":"#/components/schemas/SpreadOptimization"},"urgency":{"$ref":"#/components/schemas/Urgency"}},"required":["type","amount","tokenInChainId","tokenOutChainId","tokenIn","tokenOut","swapper"]},"LimitOrderQuoteRequest":{"type":"object","properties":{"swapper":{"$ref":"#/components/schemas/Address"},"limitPrice":{"type":"string"},"amount":{"type":"string"},"orderDeadline":{"type":"number"},"type":{"$ref":"#/components/schemas/TradeType"},"tokenIn":{"type":"string"},"tokenOut":{"type":"string"},"tokenInChainId":{"$ref":"#/components/schemas/ChainId"},"tokenOutChainId":{"$ref":"#/components/schemas/ChainId"}},"required":["swapper","type","amount","tokenIn","tokenOut","tokenInChainId","tokenOutChainId"]},"GetOrdersResponse":{"type":"object","properties":{"requestId":{"$ref":"#/components/schemas/RequestId"},"orders":{"type":"array","items":{"$ref":"#/components/schemas/UniswapXOrder"}},"cursor":{"type":"string"}},"required":["orders","requestId"]},"OrderResponse":{"type":"object","properties":{"requestId":{"$ref":"#/components/schemas/RequestId"},"orderId":{"type":"string"},"orderStatus":{"$ref":"#/components/schemas/OrderStatus"}},"required":["requestId","orderId","orderStatus"]},"OrderRequest":{"type":"object","properties":{"signature":{"type":"string","description":"The signed permit."},"quote":{"oneOf":[{"$ref":"#/components/schemas/DutchQuote"},{"$ref":"#/components/schemas/DutchQuoteV2"},{"$ref":"#/components/schemas/PriorityQuote"}]},"routing":{"$ref":"#/components/schemas/Routing"}},"required":["signature","quote"]},"Urgency":{"type":"string","enum":["normal","fast","urgent"],"description":"The urgency determines the urgency of the transaction. The default value is `urgent`.","default":"urgent"},"Protocols":{"type":"array","items":{"$ref":"#/components/schemas/ProtocolItems"},"description":"The protocols to use for the swap/order. If the `protocols` field is defined, then you can only set the `routingPreference` to `BEST_PRICE`"},"Err400":{"type":"object","properties":{"errorCode":{"default":"RequestValidationError","type":"string"},"detail":{"type":"string"}}},"Err401":{"type":"object","properties":{"errorCode":{"default":"UnauthorizedError","type":"string"},"detail":{"type":"string"}}},"Err404":{"type":"object","properties":{"errorCode":{"enum":["ResourceNotFound","QuoteAmountTooLowError"],"type":"string"},"detail":{"type":"string"}}},"Err429":{"type":"object","properties":{"errorCode":{"default":"Ratelimited","type":"string"},"detail":{"type":"string"}}},"Err500":{"type":"object","properties":{"errorCode":{"default":"InternalServerError","type":"string"},"detail":{"type":"string"}}},"Err504":{"type":"object","properties":{"errorCode":{"default":"Timeout","type":"string"},"detail":{"type":"string"}}},"ChainId":{"type":"number","enum":[1,10,56,137,8453,42161,81457,43114,42220,7777777,324,11155111,1301,480]},"OrderInput":{"type":"object","properties":{"token":{"type":"string"},"startAmount":{"type":"string"},"endAmount":{"type":"string"}},"required":["token"]},"OrderOutput":{"type":"object","properties":{"token":{"type":"string"},"startAmount":{"type":"string"},"endAmount":{"type":"string"},"isFeeOutput":{"type":"boolean"},"recipient":{"type":"string"}},"required":["token"]},"CosignerData":{"type":"object","properties":{"decayStartTime":{"type":"number"},"decayEndTime":{"type":"number"},"exclusiveFiller":{"type":"string"},"inputOverride":{"type":"string"},"outputOverrides":{"type":"array","items":{"type":"string"}}}},"SettledAmount":{"type":"object","properties":{"tokenOut":{"$ref":"#/components/schemas/Address"},"amountOut":{"type":"string"},"tokenIn":{"$ref":"#/components/schemas/Address"},"amountIn":{"type":"string"}}},"OrderType":{"type":"string","enum":["DutchLimit","Dutch","Dutch_V2"]},"OrderTypeQuery":{"type":"string","enum":["Dutch","Dutch_V2","Dutch_V1_V2","Limit","Priority"]},"UniswapXOrder":{"type":"object","properties":{"type":{"$ref":"#/components/schemas/OrderType"},"encodedOrder":{"type":"string"},"signature":{"type":"string"},"nonce":{"type":"string"},"orderStatus":{"$ref":"#/components/schemas/OrderStatus"},"orderId":{"type":"string"},"chainId":{"$ref":"#/components/schemas/ChainId"},"quoteId":{"type":"string"},"swapper":{"type":"string"},"txHash":{"type":"string"},"input":{"$ref":"#/components/schemas/OrderInput"},"outputs":{"type":"array","items":{"$ref":"#/components/schemas/OrderOutput"}},"settledAmounts":{"type":"array","items":{"$ref":"#/components/schemas/SettledAmount"}},"cosignature":{"type":"string"},"cosignerData":{"$ref":"#/components/schemas/CosignerData"}},"required":["encodedOrder","signature","nonce","orderId","orderStatus","chainId","type"]},"SortKey":{"type":"string","enum":["createdAt"]},"OrderId":{"type":"string"},"OrderIds":{"type":"string"},"OrderStatus":{"type":"string","enum":["open","expired","error","cancelled","filled","unverified","insufficient-funds"]},"Permit":{"type":"object","properties":{"domain":{"type":"object"},"values":{"type":"object"},"types":{"type":"object"}}},"TokenProject":{"type":"object","properties":{"logo":{"$ref":"#/components/schemas/TokenProjectLogo","nullable":true},"safetyLevel":{"$ref":"#/components/schemas/SafetyLevel"},"isSpam":{"type":"boolean"}},"required":["logo","safetyLevel","isSpam"]},"TokenProjectLogo":{"type":"object","properties":{"url":{"type":"string"}},"required":["url"]},"DutchInput":{"type":"object","properties":{"startAmount":{"type":"string"},"endAmount":{"type":"string"},"token":{"type":"string"}},"required":["startAmount","endAmount","type"]},"DutchOutput":{"type":"object","properties":{"startAmount":{"type":"string"},"endAmount":{"type":"string"},"token":{"type":"string"},"recipient":{"type":"string"}},"required":["startAmount","endAmount","token","recipient"]},"DutchOrderInfo":{"type":"object","properties":{"chainId":{"$ref":"#/components/schemas/ChainId"},"nonce":{"type":"string"},"reactor":{"type":"string"},"swapper":{"type":"string"},"deadline":{"type":"number"},"additionalValidationContract":{"type":"string"},"additionalValidationData":{"type":"string"},"decayStartTime":{"type":"number"},"decayEndTime":{"type":"number"},"exclusiveFiller":{"type":"string"},"exclusivityOverrideBps":{"type":"string"},"input":{"$ref":"#/components/schemas/DutchInput"},"outputs":{"type":"array","items":{"$ref":"#/components/schemas/DutchOutput"}}},"required":["chainId","nonce","reactor","swapper","deadline","validationContract","validationData","startTime","endTime","exclusiveFiller","exclusivityOverrideBps","input","outputs"]},"DutchOrderInfoV2":{"type":"object","properties":{"chainId":{"$ref":"#/components/schemas/ChainId"},"nonce":{"type":"string"},"reactor":{"type":"string"},"swapper":{"type":"string"},"deadline":{"type":"number"},"additionalValidationContract":{"type":"string"},"additionalValidationData":{"type":"string"},"input":{"$ref":"#/components/schemas/DutchInput"},"outputs":{"type":"array","items":{"$ref":"#/components/schemas/DutchOutput"}},"cosigner":{"$ref":"#/components/schemas/Address"}},"required":["chainId","nonce","reactor","swapper","deadline","validationContract","validationData","startTime","endTime","exclusiveFiller","exclusivityOverrideBps","input","outputs"]},"DutchQuote":{"type":"object","properties":{"encodedOrder":{"type":"string"},"orderId":{"type":"string"},"orderInfo":{"$ref":"#/components/schemas/DutchOrderInfo"},"portionBips":{"type":"number"},"portionAmount":{"type":"string"},"portionRecipient":{"$ref":"#/components/schemas/Address"},"quoteId":{"type":"string"},"slippageTolerance":{"type":"number"},"classicGasUseEstimateUSD":{"$ref":"#/components/schemas/ClassicGasUseEstimateUSD"}},"required":["encodedOrder","orderInfo","orderId"]},"DutchQuoteV2":{"type":"object","properties":{"encodedOrder":{"type":"string"},"orderId":{"type":"string"},"orderInfo":{"$ref":"#/components/schemas/DutchOrderInfoV2"},"portionBips":{"type":"number"},"portionAmount":{"type":"string"},"portionRecipient":{"$ref":"#/components/schemas/Address"},"quoteId":{"type":"string"},"slippageTolerance":{"type":"number"},"deadlineBufferSecs":{"type":"number"},"classicGasUseEstimateUSD":{"$ref":"#/components/schemas/ClassicGasUseEstimateUSD"}},"required":["encodedOrder","orderInfo","orderId"]},"PriorityInput":{"type":"object","properties":{"amount":{"type":"string"},"token":{"type":"string"},"mpsPerPriorityFeeWei":{"type":"string"}},"required":["amount","token","mpsPerPriorityFeeWei"]},"PriorityOutput":{"type":"object","properties":{"amount":{"type":"string"},"token":{"type":"string"},"recipient":{"type":"string"},"mpsPerPriorityFeeWei":{"type":"string"}},"required":["amount","token","recipient","mpsPerPriorityFeeWei"]},"PriorityOrderInfo":{"type":"object","properties":{"chainId":{"$ref":"#/components/schemas/ChainId"},"nonce":{"type":"string"},"reactor":{"type":"string"},"swapper":{"type":"string"},"deadline":{"type":"number"},"additionalValidationContract":{"type":"string"},"additionalValidationData":{"type":"string"},"auctionStartBlock":{"type":"string"},"baselinePriorityFeeWei":{"type":"string"},"input":{"$ref":"#/components/schemas/PriorityInput"},"outputs":{"type":"array","items":{"$ref":"#/components/schemas/PriorityOutput"}},"cosigner":{"$ref":"#/components/schemas/Address"}},"required":["chainId","nonce","reactor","swapper","deadline","validationContract","validationData","auctionStartBlock","baselinePriorityFeeWei","input","outputs","cosigner"]},"PriorityQuote":{"type":"object","properties":{"encodedOrder":{"type":"string"},"orderId":{"type":"string"},"orderInfo":{"$ref":"#/components/schemas/PriorityOrderInfo"},"portionBips":{"type":"number"},"portionAmount":{"type":"string"},"portionRecipient":{"$ref":"#/components/schemas/Address"},"quoteId":{"type":"string"},"slippageTolerance":{"type":"number"},"deadlineBufferSecs":{"type":"number"},"classicGasUseEstimateUSD":{"$ref":"#/components/schemas/ClassicGasUseEstimateUSD"}},"required":["encodedOrder","orderInfo","orderId"]},"BridgeQuote":{"type":"object","properties":{"quoteId":{"type":"string"},"chainId":{"$ref":"#/components/schemas/ChainId"},"destinationChainId":{"$ref":"#/components/schemas/ChainId"},"swapper":{"$ref":"#/components/schemas/Address"},"input":{"$ref":"#/components/schemas/ClassicInput"},"output":{"$ref":"#/components/schemas/ClassicOutput"},"tradeType":{"$ref":"#/components/schemas/TradeType"},"quoteTimestamp":{"type":"number"},"gasPrice":{"type":"string"},"maxFeePerGas":{"type":"string"},"maxPriorityFeePerGas":{"type":"string"},"gasFee":{"type":"string"},"gasUseEstimate":{"type":"string"},"gasFeeUSD":{"type":"string"},"portionBips":{"type":"number"},"portionAmount":{"type":"string"},"portionRecipient":{"$ref":"#/components/schemas/Address"},"estimatedFillTimeMs":{"type":"number"}}},"SafetyLevel":{"type":"string","enum":["BLOCKED","MEDIUM_WARNING","STRONG_WARNING","VERIFIED"]},"TradeType":{"type":"string","enum":["EXACT_INPUT","EXACT_OUTPUT"]},"Routing":{"type":"string","enum":["DUTCH_LIMIT","CLASSIC","DUTCH_V2","BRIDGE","LIMIT_ORDER","PRIORITY"]},"Quote":{"oneOf":[{"$ref":"#/components/schemas/DutchQuote"},{"$ref":"#/components/schemas/ClassicQuote"},{"$ref":"#/components/schemas/WrapUnwrapQuote"},{"$ref":"#/components/schemas/DutchQuoteV2"},{"$ref":"#/components/schemas/BridgeQuote"},{"$ref":"#/components/schemas/PriorityQuote"}]},"CheckApprovalLPRequest":{"type":"object","properties":{"protocol":{"$ref":"#/components/schemas/ProtocolItems"},"token0":{"$ref":"#/components/schemas/Address"},"token1":{"$ref":"#/components/schemas/Address"},"positionToken":{"$ref":"#/components/schemas/Address"},"chainId":{"$ref":"#/components/schemas/ChainId"},"walletAddress":{"$ref":"#/components/schemas/Address"},"amount0":{"type":"string"},"amount1":{"type":"string"},"positionAmount":{"type":"string"},"simulateTransaction":{"type":"boolean"}}},"CheckApprovalLPResponse":{"type":"object","properties":{"requestId":{"$ref":"#/components/schemas/RequestId"},"token0Approval":{"$ref":"#/components/schemas/TransactionRequest"},"token1Approval":{"$ref":"#/components/schemas/TransactionRequest"},"positionTokenApproval":{"$ref":"#/components/schemas/TransactionRequest"},"permitData":{"$ref":"#/components/schemas/NullablePermit"},"gasFeeToken0Approval":{"type":"string"},"gasFeeToken1Approval":{"type":"string"},"gasFeePositionTokenApproval":{"type":"string"}}},"ApprovalRequest":{"type":"object","properties":{"walletAddress":{"$ref":"#/components/schemas/Address"},"token":{"$ref":"#/components/schemas/Address"},"amount":{"$ref":"#/components/schemas/TokenAmount"},"chainId":{"$ref":"#/components/schemas/ChainId"},"urgency":{"$ref":"#/components/schemas/Urgency"},"includeGasInfo":{"type":"boolean","default":false},"tokenOut":{"$ref":"#/components/schemas/Address"},"tokenOutChainId":{"$ref":"#/components/schemas/ChainId"}},"required":["walletAddress","token","amount"]},"ApprovalResponse":{"type":"object","properties":{"requestId":{"$ref":"#/components/schemas/RequestId"},"approval":{"$ref":"#/components/schemas/TransactionRequest"},"cancel":{"$ref":"#/components/schemas/TransactionRequest"},"gasFee":{"type":"string"},"cancelGasFee":{"type":"string"}},"required":["requestId","approval","cancel"]},"ClassicQuote":{"type":"object","properties":{"input":{"$ref":"#/components/schemas/ClassicInput"},"output":{"$ref":"#/components/schemas/ClassicOutput"},"swapper":{"$ref":"#/components/schemas/Address"},"chainId":{"$ref":"#/components/schemas/ChainId"},"slippage":{"type":"number"},"tradeType":{"$ref":"#/components/schemas/TradeType"},"gasFee":{"type":"string","description":"The gas fee in terms of wei. It does NOT include the additional gas for token approvals."},"gasFeeUSD":{"type":"string","description":"The gas fee in terms of USD. It does NOT include the additional gas for token approvals."},"gasFeeQuote":{"type":"string","description":"The gas fee in terms of the quoted currency. It does NOT include the additional gas for token approvals."},"route":{"type":"array","items":{"type":"array","items":{"oneOf":[{"$ref":"#/components/schemas/V3PoolInRoute"},{"$ref":"#/components/schemas/V2PoolInRoute"},{"$ref":"#/components/schemas/V4PoolInRoute"}]}}},"portionBips":{"type":"number","description":"The portion of the swap that will be taken as a fee. The fee will be taken from the output token."},"portionAmount":{"type":"string","description":"The amount of the swap that will be taken as a fee. The fee will be taken from the output token."},"portionRecipient":{"$ref":"#/components/schemas/Address"},"routeString":{"type":"string","description":"The route in string format."},"quoteId":{"type":"string","description":"The quote id. Used for analytics purposes."},"gasUseEstimate":{"type":"string","description":"The estimated gas use. It does NOT include the additional gas for token approvals."},"blockNumber":{"type":"string","description":"The current block number."},"gasPrice":{"type":"string","description":"The gas price in terms of wei for pre EIP1559 transactions."},"maxFeePerGas":{"type":"string","description":"The maximum fee per gas in terms of wei for EIP1559 transactions."},"maxPriorityFeePerGas":{"type":"string","description":"The maximum priority fee per gas in terms of wei for EIP1559 transactions."},"txFailureReasons":{"type":"array","items":{"$ref":"#/components/schemas/TransactionFailureReason"}},"priceImpact":{"type":"number","description":"The impact the trade has on the market price of the pool, between 0-100 percent"}}},"WrapUnwrapQuote":{"type":"object","properties":{"swapper":{"$ref":"#/components/schemas/Address"},"input":{"$ref":"#/components/schemas/ClassicInput"},"output":{"$ref":"#/components/schemas/ClassicOutput"},"chainId":{"$ref":"#/components/schemas/ChainId"},"tradeType":{"$ref":"#/components/schemas/TradeType"},"gasFee":{"type":"string","description":"The gas fee in terms of wei."},"gasFeeUSD":{"type":"string","description":"The gas fee in terms of USD."},"gasFeeQuote":{"type":"string","description":"The gas fee in terms of the quoted currency."},"gasUseEstimate":{"type":"string","description":"The estimated gas use."},"gasPrice":{"type":"string","description":"The gas price in terms of wei for pre EIP1559 transactions."},"maxFeePerGas":{"type":"string","description":"The maximum fee per gas in terms of wei for EIP1559 transactions."},"maxPriorityFeePerGas":{"type":"string","description":"The maximum priority fee per gas in terms of wei for EIP1559 transactions."}}},"TokenInRoute":{"type":"object","properties":{"address":{"$ref":"#/components/schemas/Address"},"chainId":{"$ref":"#/components/schemas/ChainId"},"symbol":{"type":"string"},"decimals":{"type":"string"},"buyFeeBps":{"type":"string"},"sellFeeBps":{"type":"string"}}},"V2Reserve":{"type":"object","properties":{"token":{"$ref":"#/components/schemas/TokenInRoute"},"quotient":{"type":"string"}}},"V2PoolInRoute":{"type":"object","properties":{"type":{"type":"string","default":"v2-pool"},"address":{"$ref":"#/components/schemas/Address"},"tokenIn":{"$ref":"#/components/schemas/TokenInRoute"},"tokenOut":{"$ref":"#/components/schemas/TokenInRoute"},"reserve0":{"$ref":"#/components/schemas/V2Reserve"},"reserve1":{"$ref":"#/components/schemas/V2Reserve"},"amountIn":{"type":"string"},"amountOut":{"type":"string"}}},"V3PoolInRoute":{"type":"object","properties":{"type":{"type":"string","default":"v3-pool"},"address":{"$ref":"#/components/schemas/Address"},"tokenIn":{"$ref":"#/components/schemas/TokenInRoute"},"tokenOut":{"$ref":"#/components/schemas/TokenInRoute"},"sqrtRatioX96":{"type":"string"},"liquidity":{"type":"string"},"tickCurrent":{"type":"string"},"fee":{"type":"string"},"amountIn":{"type":"string"},"amountOut":{"type":"string"}}},"V4PoolInRoute":{"type":"object","properties":{"type":{"type":"string","default":"v4-pool"},"address":{"$ref":"#/components/schemas/Address"},"tokenIn":{"$ref":"#/components/schemas/TokenInRoute"},"tokenOut":{"$ref":"#/components/schemas/TokenInRoute"},"sqrtRatioX96":{"type":"string"},"liquidity":{"type":"string"},"tickCurrent":{"type":"string"},"fee":{"type":"string"},"tickSpacing":{"type":"string"},"hooks":{"type":"string"},"amountIn":{"type":"string"},"amountOut":{"type":"string"}},"required":["type","address","tokenIn","tokenOut","sqrtRatioX96","liquidity","tickCurrent","fee","tickSpacing","hooks"]},"TransactionHash":{"type":"string","pattern":"^(0x)?[0-9a-fA-F]{64}$"},"ClassicInput":{"type":"object","properties":{"token":{"$ref":"#/components/schemas/Address"},"amount":{"type":"string"}}},"ClassicOutput":{"type":"object","properties":{"token":{"$ref":"#/components/schemas/Address"},"amount":{"type":"string"},"recipient":{"$ref":"#/components/schemas/Address"}}},"RequestId":{"type":"string"},"SpreadOptimization":{"type":"string","enum":["EXECUTION","PRICE"],"description":"For **Dutch Limit** orders only. When set to `EXECUTION`, quotes optimize for looser spreads at higher fill rates. When set to `PRICE`, quotes optimize for tighter spreads at lower fill rates","default":"EXECUTION"},"AutoSlippage":{"type":"string","enum":["DEFAULT"],"description":"For **Classic** swaps only. The auto slippage strategy to employ. If auto slippage is not defined then we don't compute it. If the auto slippage strategy is `DEFAULT`, then the swap will use the default slippage tolerance computation. You cannot define auto slippage and slippage tolerance at the same time. \n\n **NOTE**: slippage is in terms of trade type. If the trade type is `EXACT_INPUT`, then the slippage is in terms of the output token. If the trade type is `EXACT_OUTPUT`, then the slippage is in terms of the input token.","default":"undefined"},"RoutingPreference":{"type":"string","description":"The routing preference determines which protocol to use for the swap. If the routing preference is `UNISWAPX`, then the swap will be routed through the UniswapX Dutch Auction Protocol. If the routing preference is `CLASSIC`, then the swap will be routed through the Classic Protocol. If the routing preference is `BEST_PRICE`, then the swap will be routed through the protocol that provides the best price. When `UNIXWAPX_V2` is passed, the swap will be routed through the UniswapX V2 Dutch Auction Protocol. When `V3_ONLY` is passed, the swap will be routed ONLY through the Uniswap V3 Protocol. When `V2_ONLY` is passed, the swap will be routed ONLY through the Uniswap V2 Protocol.","enum":["CLASSIC","UNISWAPX","BEST_PRICE","BEST_PRICE_V2","UNISWAPX_V2","V3_ONLY","V2_ONLY"],"default":"BEST_PRICE"},"ProtocolItems":{"type":"string","enum":["V2","V3","V4","UNISWAPX","UNISWAPX_V2","PRIORITY"]},"TransactionRequest":{"type":"object","properties":{"to":{"$ref":"#/components/schemas/Address"},"from":{"$ref":"#/components/schemas/Address"},"data":{"type":"string","description":"The calldata for the transaction."},"value":{"type":"string","description":"The value of the transaction in terms of wei in hex format."},"gasLimit":{"type":"string"},"chainId":{"type":"integer"},"maxFeePerGas":{"type":"string"},"maxPriorityFeePerGas":{"type":"string"},"gasPrice":{"type":"string"}},"required":["to","from","data","value","chainId"]},"TransactionFailureReason":{"type":"string","enum":["SIMULATION_ERROR","UNSUPPORTED_SIMULATION"]},"SwapSafetyMode":{"type":"string","enum":["SAFE"],"description":"The safety mode determines the safety level of the swap. If the safety mode is `SAFE`, then the swap will include a SWEEP for the native token."},"IndicativeQuoteRequest":{"type":"object","properties":{"type":{"$ref":"#/components/schemas/TradeType"},"amount":{"type":"string"},"tokenInChainId":{"$ref":"#/components/schemas/ChainId"},"tokenOutChainId":{"$ref":"#/components/schemas/ChainId"},"tokenIn":{"type":"string"},"tokenOut":{"type":"string"}},"required":["type","amount","tokenInChainId","tokenOutChainId","tokenIn","tokenOut"]},"IndicativeQuoteResponse":{"type":"object","properties":{"requestId":{"$ref":"#/components/schemas/RequestId"},"input":{"$ref":"#/components/schemas/IndicativeQuoteToken"},"output":{"$ref":"#/components/schemas/IndicativeQuoteToken"},"type":{"$ref":"#/components/schemas/TradeType"}},"required":["requestId","input","output","type"]},"CreateLPPositionRequest":{"type":"object","properties":{"protocol":{"$ref":"#/components/schemas/ProtocolItems"},"position":{"$ref":"#/components/schemas/Position"},"walletAddress":{"$ref":"#/components/schemas/Address"},"chainId":{"$ref":"#/components/schemas/ChainId"},"initialPrice":{"type":"string"},"poolLiquidity":{"type":"string"},"currentTick":{"type":"number"},"sqrtRatioX96":{"type":"string"},"amount0":{"type":"string"},"amount1":{"type":"string"},"slippageTolerance":{"type":"string"},"deadline":{"type":"number"},"signature":{"type":"string","description":"The signed permit."},"batchPermitData":{"allOf":[{"$ref":"#/components/schemas/Permit"}]},"simulateTransaction":{"type":"boolean"}}},"CreateLPPositionResponse":{"type":"object","properties":{"requestId":{"$ref":"#/components/schemas/RequestId"},"create":{"$ref":"#/components/schemas/TransactionRequest"},"gasFee":{"type":"string"}}},"IncreaseLPPositionRequest":{"type":"object","properties":{"protocol":{"$ref":"#/components/schemas/ProtocolItems"},"tokenId":{"type":"number"},"position":{"$ref":"#/components/schemas/Position"},"poolLiquidity":{"type":"string"},"currentTick":{"type":"number"},"sqrtRatioX96":{"type":"string"},"walletAddress":{"$ref":"#/components/schemas/Address"},"chainId":{"$ref":"#/components/schemas/ChainId"},"amount0":{"type":"string"},"amount1":{"type":"string"},"slippageTolerance":{"type":"string"},"deadline":{"type":"number"},"signature":{"type":"string","description":"The signed permit."},"batchPermitData":{"allOf":[{"$ref":"#/components/schemas/Permit"}]},"simulateTransaction":{"type":"boolean"}}},"IncreaseLPPositionResponse":{"type":"object","properties":{"requestId":{"$ref":"#/components/schemas/RequestId"},"increase":{"$ref":"#/components/schemas/TransactionRequest"},"gasFee":{"type":"string"}}},"DecreaseLPPositionRequest":{"type":"object","properties":{"protocol":{"$ref":"#/components/schemas/ProtocolItems"},"tokenId":{"type":"number"},"position":{"$ref":"#/components/schemas/Position"},"walletAddress":{"$ref":"#/components/schemas/Address"},"chainId":{"$ref":"#/components/schemas/ChainId"},"liquidityPercentageToDecrease":{"type":"number"},"slippageTolerance":{"type":"string"},"poolLiquidity":{"type":"string"},"currentTick":{"type":"number"},"sqrtRatioX96":{"type":"string"},"positionLiquidity":{"type":"string"},"expectedTokenOwed0RawAmount":{"type":"string"},"expectedTokenOwed1RawAmount":{"type":"string"},"deadline":{"type":"number"},"simulateTransaction":{"type":"boolean"}}},"DecreaseLPPositionResponse":{"type":"object","properties":{"requestId":{"$ref":"#/components/schemas/RequestId"},"decrease":{"$ref":"#/components/schemas/TransactionRequest"},"gasFee":{"type":"string"}}},"ClaimLPFeesRequest":{"type":"object","properties":{"protocol":{"$ref":"#/components/schemas/ProtocolItems"},"tokenId":{"type":"number"},"position":{"$ref":"#/components/schemas/Position"},"walletAddress":{"$ref":"#/components/schemas/Address"},"chainId":{"$ref":"#/components/schemas/ChainId"},"expectedTokenOwed0RawAmount":{"type":"string"},"expectedTokenOwed1RawAmount":{"type":"string"},"simulateTransaction":{"type":"boolean"}}},"ClaimLPFeesResponse":{"type":"object","properties":{"requestId":{"$ref":"#/components/schemas/RequestId"},"claim":{"$ref":"#/components/schemas/TransactionRequest"},"gasFee":{"type":"string"}}},"MigrateLPPositionRequest":{"type":"object","properties":{"chainId":{"$ref":"#/components/schemas/ChainId"},"tokenId":{"type":"number"},"walletAddress":{"$ref":"#/components/schemas/Address"},"signature":{"type":"string"},"simulateTransaction":{"type":"boolean","default":false}},"required":["chainId","tokenId","walletAddress"]},"MigrateLPPositionResponse":{"type":"object","properties":{"requestId":{"$ref":"#/components/schemas/RequestId"},"migrate":{"$ref":"#/components/schemas/TransactionRequest"},"gasFee":{"type":"string"}}},"IndicativeQuoteToken":{"type":"object","properties":{"amount":{"type":"string"},"chainId":{"$ref":"#/components/schemas/ChainId"},"token":{"$ref":"#/components/schemas/Address"}}}},"parameters":{"universalRouterVersionHeader":{"name":"x-universal-router-version","in":"header","description":"The version of the Universal Router to use for the swap journey. *MUST* be consistent throughout the API calls.","required":false,"schema":{"$ref":"#/components/schemas/UniversalRouterVersion"}},"addressParam":{"name":"address","in":"path","schema":{"$ref":"#/components/schemas/Address"},"required":true},"tokenIdParam":{"name":"tokenId","in":"path","schema":{"type":"string"},"required":true},"cursorParam":{"name":"cursor","in":"query","schema":{"type":"string"},"required":false},"limitParam":{"name":"limit","in":"query","schema":{"type":"number"},"required":false},"chainIdParam":{"name":"chainId","in":"query","schema":{"$ref":"#/components/schemas/ChainId"},"required":false},"bridgeTokenInChainIdParam":{"name":"tokenInChainId","in":"query","schema":{"$ref":"#/components/schemas/ChainId"},"required":false},"bridgeTokenOutChainIdParam":{"name":"tokenOutChainId","in":"query","schema":{"$ref":"#/components/schemas/ChainId"},"required":false},"tokenInParam":{"name":"tokenIn","in":"query","schema":{"$ref":"#/components/schemas/Address"},"required":false},"tokenOutParam":{"name":"tokenOut","in":"query","schema":{"$ref":"#/components/schemas/Address"},"required":false},"addressPathParam":{"name":"address","in":"query","schema":{"$ref":"#/components/schemas/Address"},"required":false},"orderStatusParam":{"name":"orderStatus","in":"query","description":"Filter by order status.","required":false,"schema":{"$ref":"#/components/schemas/OrderStatus"}},"orderTypeParam":{"name":"orderType","in":"query","description":"The default orderType is Dutch_V1_V2 and will grab both Dutch and Dutch_V2 orders.","required":false,"schema":{"$ref":"#/components/schemas/OrderTypeQuery"}},"orderIdParam":{"name":"orderId","in":"query","required":false,"schema":{"$ref":"#/components/schemas/OrderId"}},"orderIdsParam":{"name":"orderIds","in":"query","required":false,"description":"ids split by commas","schema":{"$ref":"#/components/schemas/OrderIds"}},"swapperParam":{"name":"swapper","in":"query","description":"Filter by swapper address.","required":false,"schema":{"$ref":"#/components/schemas/Address"}},"fillerParam":{"name":"filler","in":"query","description":"Filter by filler address.","required":false,"schema":{"$ref":"#/components/schemas/Address"}},"sortKeyParam":{"name":"sortKey","in":"query","description":"Order the query results by the sort key.","required":false,"schema":{"$ref":"#/components/schemas/SortKey"}},"sortParam":{"name":"sort","in":"query","description":"Sort query. For example: `sort=gt(UNIX_TIMESTAMP)`, `sort=between(1675872827, 1675872930)`, or `lt(1675872930)`.","required":false,"schema":{"type":"string"}},"descParam":{"description":"Sort query results by sortKey in descending order.","name":"desc","in":"query","required":false,"schema":{"type":"string"}},"transactionHashesParam":{"description":"The transaction hashes.","name":"txHashes","in":"query","required":true,"style":"form","explode":false,"schema":{"type":"array","items":{"$ref":"#/components/schemas/TransactionHash"}}}},"securitySchemes":{"apiKey":{"type":"apiKey","in":"header","name":"x-api-key"}}},"security":[{"apiKey":[]}]} \ No newline at end of file +{"openapi":"3.0.0","servers":[{"description":"Uniswap trading APIs Beta","url":"https://beta.trade-api.gateway.uniswap.org/v1"},{"description":"Uniswap trading APIs","url":"https://trade-api.gateway.uniswap.org/v1"}],"info":{"version":"1.0.0","title":"Token Trading","description":"Uniswap trading APIs for fungible tokens."},"paths":{"/check_approval":{"post":{"tags":["Approval"],"summary":"Check if token approval is required","description":"Checks if the swapper has the required approval. If the swapper does not have the required approval, then the response will include the transaction to approve the token. If the swapper has the required approval, then the response will be empty. If the parameter `includeGasInfo` is set to `true`, then the response will include the gas fee for the approval transaction.","operationId":"check_approval","security":[{"apiKey":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApprovalRequest"}}}},"responses":{"200":{"$ref":"#/components/responses/ApprovalSuccess200"},"400":{"$ref":"#/components/responses/BadRequest400"},"401":{"$ref":"#/components/responses/ApprovalUnauthorized401"},"404":{"$ref":"#/components/responses/ApprovalNotFound404"},"419":{"$ref":"#/components/responses/RateLimitedErr429"},"500":{"$ref":"#/components/responses/InternalErr500"},"504":{"$ref":"#/components/responses/Timeout504"}}}},"/quote":{"post":{"tags":["Quote"],"summary":"Get a quote","description":"Get a quote according to the provided configuration. Optionally adds a fee to the quote according to the API key being used. The fee is **ALWAYS** taken from the output token. If there is a fee and the trade is `EXACT_INPUT`, then the output amount will **NOT** include the fee subtraction. For `EXACT_INPUT` swaps, use `portionBips` to calculate the fee from the quoted amount. If there is a fee and the trade is `EXACT_OUTPUT`, then the input amount will **NOT** include the fee addition to account for the fee. For `EXACT_OUTPUT` swaps, use `portionAmount` to get the fee. \n \n We also support Wrapping and Unwrapping of native tokens on their respective chains. Wrapping and Unwrapping only works for when `routingPreference` is `CLASSIC`, `BEST_PRICE`, or `BEST_PRICE_V2`. We do not support `UNISWAPX` or `UNISWAPX_V2` for these actions.","operationId":"aggregator_quote","security":[{"apiKey":[]}],"parameters":[{"$ref":"#/components/parameters/universalRouterVersionHeader"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuoteRequest"}}}},"responses":{"200":{"$ref":"#/components/responses/QuoteSuccess200"},"400":{"$ref":"#/components/responses/BadRequest400"},"401":{"$ref":"#/components/responses/Unauthorized401"},"404":{"$ref":"#/components/responses/QuoteNotFound404"},"419":{"$ref":"#/components/responses/RateLimitedErr429"},"500":{"$ref":"#/components/responses/InternalErr500"},"504":{"$ref":"#/components/responses/Timeout504"}}}},"/order":{"post":{"tags":["Order"],"summary":"Create a gasless order","description":"Submits a new gasless encoded order. The order will be validated and if valid, will be submitted to the filler network. The network will try to fill the order at the quoted `startAmount`, and if not, the amount will start decaying until the `endAmount` is reached. While the order is within `decayEndTime`, the `orderStatus` is `open`. If the order does not get filled after the `decayEndTime` has passed, that is reflected in the `expired` `orderStatus`. then The order will be filled at the best price possible. Once the order is filled, `orderStatus` becomes `filled`.","operationId":"post_order","security":[{"apiKey":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrderRequest"}}}},"responses":{"201":{"$ref":"#/components/responses/OrderSuccess201"},"400":{"$ref":"#/components/responses/BadRequest400"},"401":{"$ref":"#/components/responses/Unauthorized401"},"419":{"$ref":"#/components/responses/RateLimitedErr429"},"500":{"$ref":"#/components/responses/InternalErr500"},"504":{"$ref":"#/components/responses/Timeout504"}}}},"/orders":{"get":{"tags":["Order"],"summary":"Get gasless orders","description":"Retrieve gasless orders filtered by query param(s). Some fields on the order can be used as query param.","operationId":"get_order","security":[{"apiKey":[]}],"parameters":[{"$ref":"#/components/parameters/orderTypeParam"},{"$ref":"#/components/parameters/orderIdParam"},{"$ref":"#/components/parameters/orderIdsParam"},{"$ref":"#/components/parameters/limitParam"},{"$ref":"#/components/parameters/orderStatusParam"},{"$ref":"#/components/parameters/swapperParam"},{"$ref":"#/components/parameters/sortKeyParam"},{"$ref":"#/components/parameters/sortParam"},{"$ref":"#/components/parameters/fillerParam"},{"$ref":"#/components/parameters/cursorParam"}],"responses":{"200":{"$ref":"#/components/responses/OrdersSuccess200"},"400":{"$ref":"#/components/responses/OrdersBadRequest400"},"404":{"$ref":"#/components/responses/OrdersNotFound404"},"419":{"$ref":"#/components/responses/RateLimitedErr429"},"500":{"$ref":"#/components/responses/InternalErr500"},"504":{"$ref":"#/components/responses/Timeout504"}}}},"/swap":{"post":{"tags":["Swap"],"summary":"Create swap calldata","description":"Create the calldata for a swap transaction (including wrap/unwrap) against the Uniswap Protocols. If the `quote` parameter includes the fee parameters, then the calldata will include the fee disbursement. The gas estimates will be **more precise** when the the response calldata would be valid if submitted on-chain.","operationId":"create_swap_transaction","security":[{"apiKey":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSwapRequest"}}}},"parameters":[{"$ref":"#/components/parameters/universalRouterVersionHeader"}],"responses":{"200":{"$ref":"#/components/responses/CreateSwapSuccess200"},"400":{"$ref":"#/components/responses/BadRequest400"},"401":{"$ref":"#/components/responses/SwapUnauthorized401"},"404":{"$ref":"#/components/responses/SwapNotFound404"},"419":{"$ref":"#/components/responses/RateLimitedErr429"},"500":{"$ref":"#/components/responses/InternalErr500"},"504":{"$ref":"#/components/responses/Timeout504"}}}},"/swaps":{"get":{"tags":["Swap"],"summary":"Get swaps status","description":"Get the status of a swap or bridge transactions.","operationId":"get_swaps","security":[{"apiKey":[]}],"parameters":[{"$ref":"#/components/parameters/transactionHashesParam"},{"$ref":"#/components/parameters/chainIdParam"}],"responses":{"200":{"$ref":"#/components/responses/GetSwapsSuccess200"},"400":{"$ref":"#/components/responses/BadRequest400"},"404":{"$ref":"#/components/responses/SwapNotFound404"},"419":{"$ref":"#/components/responses/RateLimitedErr429"},"500":{"$ref":"#/components/responses/InternalErr500"},"504":{"$ref":"#/components/responses/Timeout504"}}}},"/indicative_quote":{"post":{"tags":["IndicativeQuote"],"summary":"Get an indicative quote","description":"Get an indicative quote according to the provided configuration. The quote will not include a fee.","operationId":"indicative_quote","security":[{"apiKey":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IndicativeQuoteRequest"}}}},"responses":{"200":{"$ref":"#/components/responses/IndicativeQuoteSuccess200"},"400":{"$ref":"#/components/responses/BadRequest400"},"404":{"$ref":"#/components/responses/QuoteNotFound404"},"419":{"$ref":"#/components/responses/RateLimitedErr429"},"500":{"$ref":"#/components/responses/InternalErr500"},"504":{"$ref":"#/components/responses/Timeout504"}}}},"/send":{"post":{"tags":["Send"],"summary":"Create send calldata","description":"Create the calldata for a send transaction.","operationId":"create_send","security":[{"apiKey":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSendRequest"}}}},"responses":{"200":{"$ref":"#/components/responses/CreateSendSuccess200"},"400":{"$ref":"#/components/responses/BadRequest400"},"401":{"$ref":"#/components/responses/Unauthorized401"},"404":{"$ref":"#/components/responses/SendNotFound404"},"429":{"$ref":"#/components/responses/RateLimitedErr429"},"500":{"$ref":"#/components/responses/InternalErr500"},"504":{"$ref":"#/components/responses/Timeout504"}}}},"/swappable_tokens":{"get":{"tags":["SwappableTokens"],"summary":"Get swappable tokens","description":"Get the swappable tokens for the given configuration. Either tokenIn (with tokenInChainId or (tokenInChainId and tokenOutChainId)) or tokenOut (with tokenOutChainId or (tokenOutChainId and tokenInChainId)) must be provided but not both.","operationId":"get_swappable_tokens","security":[{"apiKey":[]}],"parameters":[{"$ref":"#/components/parameters/tokenInParam"},{"$ref":"#/components/parameters/tokenOutParam"},{"$ref":"#/components/parameters/bridgeTokenInChainIdParam"},{"$ref":"#/components/parameters/bridgeTokenOutChainIdParam"}],"responses":{"200":{"$ref":"#/components/responses/GetSwappableTokensSuccess200"},"400":{"$ref":"#/components/responses/BadRequest400"},"401":{"$ref":"#/components/responses/Unauthorized401"},"404":{"$ref":"#/components/responses/QuoteNotFound404"},"429":{"$ref":"#/components/responses/RateLimitedErr429"},"500":{"$ref":"#/components/responses/InternalErr500"},"504":{"$ref":"#/components/responses/Timeout504"}}}},"/limit_order_quote":{"post":{"tags":["LimitOrderQuote"],"summary":"Get a limit order quote","description":"Get a quote for a limit order according to the provided configuration.","operationId":"get_limit_order_quote","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LimitOrderQuoteRequest"}}}},"responses":{"200":{"$ref":"#/components/responses/LimitOrderQuoteSuccess200"},"400":{"$ref":"#/components/responses/BadRequest400"},"401":{"$ref":"#/components/responses/Unauthorized401"},"404":{"$ref":"#/components/responses/QuoteNotFound404"},"419":{"$ref":"#/components/responses/RateLimitedErr429"},"500":{"$ref":"#/components/responses/InternalErr500"},"504":{"$ref":"#/components/responses/Timeout504"}}}},"/lp/approve":{"post":{"tags":["Liquidity"],"summary":"Check if tokens and permits need to be approved to add liquidity","description":"Checks if the wallet address has the required approvals. If the wallet address does not have the required approval, then the response will include the transactions to approve the tokens. If the wallet address has the required approval, then the response will be empty for the corresponding tokens. If the parameter `simulateTransaction` is set to `true`, then the response will include the gas fees for the approval transactions.","operationId":"check_approval_lp","security":[{"apiKey":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckApprovalLPRequest"}}}},"responses":{"200":{"$ref":"#/components/responses/CheckApprovalLPSuccess200"},"400":{"$ref":"#/components/responses/BadRequest400"},"401":{"$ref":"#/components/responses/ApprovalUnauthorized401"},"404":{"$ref":"#/components/responses/ApprovalNotFound404"},"419":{"$ref":"#/components/responses/RateLimitedErr429"},"500":{"$ref":"#/components/responses/InternalErr500"},"504":{"$ref":"#/components/responses/Timeout504"}}}},"/lp/create":{"post":{"tags":["Liquidity"],"summary":"Create pool and position calldata","description":"Create pool and position calldata. If the pool is not yet created, then the response will include the transaction to create the new pool with the initial price. If the pool is already created, then the response will not have the transaction to create the pool. The response will also have the transaction to create the position for the corresponding pool. If the parameter `simulateTransaction` is set to `true`, then the response will include the gas fees for the creation transactions.","operationId":"create_lp_position","security":[{"apiKey":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateLPPositionRequest"}}}},"responses":{"200":{"$ref":"#/components/responses/CreateLPPositionSuccess200"},"400":{"$ref":"#/components/responses/BadRequest400"},"401":{"$ref":"#/components/responses/ApprovalUnauthorized401"},"404":{"$ref":"#/components/responses/LPNotFound404"},"419":{"$ref":"#/components/responses/RateLimitedErr429"},"500":{"$ref":"#/components/responses/InternalErr500"},"504":{"$ref":"#/components/responses/Timeout504"}}}},"/lp/increase":{"post":{"tags":["Liquidity"],"summary":"Increase LP position calldata","description":"The response will also have the transaction to increase the position for the corresponding pool. If the parameter `simulateTransaction` is set to `true`, then the response will include the gas fees for the increase transaction.","operationId":"increase_lp_position","security":[{"apiKey":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IncreaseLPPositionRequest"}}}},"responses":{"200":{"$ref":"#/components/responses/IncreaseLPPositionSuccess200"},"400":{"$ref":"#/components/responses/BadRequest400"},"401":{"$ref":"#/components/responses/ApprovalUnauthorized401"},"404":{"$ref":"#/components/responses/LPNotFound404"},"419":{"$ref":"#/components/responses/RateLimitedErr429"},"500":{"$ref":"#/components/responses/InternalErr500"},"504":{"$ref":"#/components/responses/Timeout504"}}}},"/lp/decrease":{"post":{"tags":["Liquidity"],"summary":"Decrease LP position calldata","description":"The response will also have the transaction to decrease the position for the corresponding pool. If the parameter `simulateTransaction` is set to `true`, then the response will include the gas fees for the decrease transaction.","operationId":"decrease_lp_position","security":[{"apiKey":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DecreaseLPPositionRequest"}}}},"responses":{"200":{"$ref":"#/components/responses/DecreaseLPPositionSuccess200"},"400":{"$ref":"#/components/responses/BadRequest400"},"401":{"$ref":"#/components/responses/ApprovalUnauthorized401"},"404":{"$ref":"#/components/responses/LPNotFound404"},"419":{"$ref":"#/components/responses/RateLimitedErr429"},"500":{"$ref":"#/components/responses/InternalErr500"},"504":{"$ref":"#/components/responses/Timeout504"}}}},"/lp/claim":{"post":{"tags":["Liquidity"],"summary":"Claim LP fees calldata","description":"The response will also have the transaction to claim the fees for an LP position for the corresponding pool. If the parameter `simulateTransaction` is set to `true`, then the response will include the gas fees for the claim transaction.","operationId":"claim_lp_fees","security":[{"apiKey":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClaimLPFeesRequest"}}}},"responses":{"200":{"$ref":"#/components/responses/ClaimLPFeesSuccess200"},"400":{"$ref":"#/components/responses/BadRequest400"},"401":{"$ref":"#/components/responses/ApprovalUnauthorized401"},"404":{"$ref":"#/components/responses/LPNotFound404"},"419":{"$ref":"#/components/responses/RateLimitedErr429"},"500":{"$ref":"#/components/responses/InternalErr500"},"504":{"$ref":"#/components/responses/Timeout504"}}}},"/lp/migrate":{"post":{"tags":["Liquidity"],"summary":"Migrate LP position calldata","description":"The response will also have the transaction to migrate the position for the corresponding pool. If the parameter `simulateTransaction` is set to `true`, then the response will include the gas fees for the migrate transaction.","operationId":"migrate_lp_position","security":[{"apiKey":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MigrateLPPositionRequest"}}}},"responses":{"200":{"$ref":"#/components/responses/MigrateLPPositionSuccess200"},"400":{"$ref":"#/components/responses/BadRequest400"},"401":{"$ref":"#/components/responses/ApprovalUnauthorized401"},"404":{"$ref":"#/components/responses/LPNotFound404"},"419":{"$ref":"#/components/responses/RateLimitedErr429"},"500":{"$ref":"#/components/responses/InternalErr500"},"504":{"$ref":"#/components/responses/Timeout504"}}}}},"components":{"responses":{"OrdersSuccess200":{"description":"The request orders matching the query parameters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetOrdersResponse"}}}},"OrderSuccess201":{"description":"Encoded order submitted.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrderResponse"}}}},"QuoteSuccess200":{"description":"Quote request successful.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuoteResponse"}}}},"LimitOrderQuoteSuccess200":{"description":"Limit Order Quote request successful.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LimitOrderQuoteResponse"}}}},"CheckApprovalLPSuccess200":{"description":"Approve LP successful.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckApprovalLPResponse"}}}},"ApprovalSuccess200":{"description":"Check approval successful.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApprovalResponse"}}}},"CreateSendSuccess200":{"description":"Create send successful.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSendResponse"}}}},"CreateSwapSuccess200":{"description":"Create swap successful.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSwapResponse"}}}},"GetSwapsSuccess200":{"description":"Get swap successful.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetSwapsResponse"}}}},"GetSwappableTokensSuccess200":{"description":"Get swappable tokens successful.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetSwappableTokensResponse"}}}},"CreateLPPositionSuccess200":{"description":"Create LP Position successful.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateLPPositionResponse"}}}},"IncreaseLPPositionSuccess200":{"description":"Create LP Position successful.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/IncreaseLPPositionResponse"}}}},"DecreaseLPPositionSuccess200":{"description":"Decrease LP Position successful.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DecreaseLPPositionResponse"}}}},"ClaimLPFeesSuccess200":{"description":"Claim LP Fees successful.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClaimLPFeesResponse"}}}},"MigrateLPPositionSuccess200":{"description":"Migrate LP Position successful.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MigrateLPPositionResponse"}}}},"BadRequest400":{"description":"RequestValidationError, Bad Input","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Err400"}}}},"ApprovalUnauthorized401":{"description":"UnauthorizedError eg. Account is blocked.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Err401"}}}},"ApprovalNotFound404":{"description":"ResourceNotFound eg. Token allowance not found or Gas info not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Err404"}}}},"Unauthorized401":{"description":"UnauthorizedError eg. Account is blocked.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Err401"}}}},"QuoteNotFound404":{"description":"ResourceNotFound eg. No quotes available or Gas fee/price not available","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Err404"}}}},"SendNotFound404":{"description":"ResourceNotFound eg. Gas fee not available","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Err404"}}}},"SwapBadRequest400":{"description":"RequestValidationError, Bad Input","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Err400"}}}},"SwapUnauthorized401":{"description":"UnauthorizedError eg. Account is blocked or Fee is not enabled.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Err401"}}}},"SwapNotFound404":{"description":"ResourceNotFound eg. No quotes available or Gas fee/price not available","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Err404"}}}},"OrdersNotFound404":{"description":"Orders not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Err404"}}}},"LPNotFound404":{"description":"ResourceNotFound eg. Cant Find LP Position.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Err404"}}}},"OrdersBadRequest400":{"description":"RequestValidationError eg. Token allowance not valid or Insufficient Funds.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Err400"}}}},"RateLimitedErr429":{"description":"Ratelimited","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Err429"}}}},"InternalErr500":{"description":"Unexpected error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Err500"}}}},"Timeout504":{"description":"Request duration limit reached.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Err504"}}}},"IndicativeQuoteSuccess200":{"description":"Indicative quote request successful.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/IndicativeQuoteResponse"}}}}},"schemas":{"NullablePermit":{"allOf":[{"$ref":"#/components/schemas/Permit"},{"type":"object","nullable":true}]},"TokenAmount":{"type":"string"},"SwapStatus":{"type":"string","enum":["PENDING","SUCCESS","NOT_FOUND","FAILED","EXPIRED"]},"GetSwapsResponse":{"type":"object","properties":{"requestId":{"$ref":"#/components/schemas/RequestId"},"swaps":{"type":"array","items":{"type":"object","properties":{"swapType":{"$ref":"#/components/schemas/Routing"},"status":{"$ref":"#/components/schemas/SwapStatus"},"txHash":{"type":"string"},"swapId":{"type":"number"}}}}},"required":["requestId","status"]},"GetSwappableTokensResponse":{"type":"object","properties":{"requestId":{"$ref":"#/components/schemas/RequestId"},"tokens":{"type":"array","items":{"type":"object","properties":{"address":{"$ref":"#/components/schemas/Address"},"chainId":{"$ref":"#/components/schemas/ChainId"},"name":{"type":"string"},"symbol":{"type":"string"},"project":{"$ref":"#/components/schemas/TokenProject"},"isSpam":{"type":"boolean"},"decimals":{"type":"number"}},"required":["address","chainId","name","symbol","project","decimals"]}}},"required":["requestId","tokens"]},"CreateSwapRequest":{"type":"object","description":"The parameters **signature** and **permitData** should only be included if *permitData* was returned from **/quote**.","properties":{"quote":{"oneOf":[{"$ref":"#/components/schemas/ClassicQuote"},{"$ref":"#/components/schemas/WrapUnwrapQuote"},{"$ref":"#/components/schemas/BridgeQuote"}]},"signature":{"type":"string","description":"The signed permit."},"includeGasInfo":{"type":"boolean","default":false,"deprecated":true,"description":"Use `refreshGasPrice` instead."},"refreshGasPrice":{"type":"boolean","default":false,"description":"If true, the gas price will be re-fetched from the network."},"simulateTransaction":{"type":"boolean","default":false,"description":"If true, the transaction will be simulated. If the simulation results on an onchain error, endpoint will return an error."},"permitData":{"allOf":[{"$ref":"#/components/schemas/Permit"}]},"safetyMode":{"$ref":"#/components/schemas/SwapSafetyMode"},"deadline":{"type":"integer","description":"The deadline for the swap in unix timestamp format. If the deadline is not defined OR in the past then the default deadline is 30 minutes."},"urgency":{"$ref":"#/components/schemas/Urgency"}},"required":["quote"]},"CreateSendRequest":{"type":"object","properties":{"sender":{"$ref":"#/components/schemas/Address"},"recipient":{"$ref":"#/components/schemas/Address"},"token":{"$ref":"#/components/schemas/Address"},"amount":{"$ref":"#/components/schemas/TokenAmount"},"chainId":{"$ref":"#/components/schemas/ChainId"},"urgency":{"$ref":"#/components/schemas/Urgency"}},"required":["sender","recipient","token","amount"]},"UniversalRouterVersion":{"type":"string","enum":["1.2","2.0"],"default":"1.2"},"Address":{"type":"string","pattern":"^(0x)?[0-9a-fA-F]{40}$"},"Position":{"type":"object","properties":{"pool":{"$ref":"#/components/schemas/Pool"},"tickLower":{"type":"number"},"tickUpper":{"type":"number"}},"required":["pool"]},"Pool":{"type":"object","properties":{"token0":{"$ref":"#/components/schemas/Address"},"token1":{"$ref":"#/components/schemas/Address"},"fee":{"type":"number"},"tickSpacing":{"type":"number"},"hooks":{"$ref":"#/components/schemas/Address"}},"required":["token0","token1"]},"ClassicGasUseEstimateUSD":{"description":"The gas fee you would pay if you opted for a CLASSIC swap over a Uniswap X order in terms of USD.","type":"string"},"CreateSwapResponse":{"type":"object","properties":{"requestId":{"$ref":"#/components/schemas/RequestId"},"swap":{"$ref":"#/components/schemas/TransactionRequest"},"gasFee":{"type":"string"}},"required":["requestId","swap"]},"CreateSendResponse":{"type":"object","properties":{"requestId":{"$ref":"#/components/schemas/RequestId"},"send":{"$ref":"#/components/schemas/TransactionRequest"},"gasFee":{"type":"string"},"gasFeeUSD":{"type":"number"}},"required":["requestId","send"]},"QuoteResponse":{"type":"object","properties":{"requestId":{"$ref":"#/components/schemas/RequestId"},"quote":{"$ref":"#/components/schemas/Quote"},"routing":{"$ref":"#/components/schemas/Routing"},"permitData":{"$ref":"#/components/schemas/NullablePermit"}},"required":["routing","quote","permitData","requestId"]},"LimitOrderQuoteResponse":{"type":"object","properties":{"requestId":{"$ref":"#/components/schemas/RequestId"},"quote":{"$ref":"#/components/schemas/DutchQuote"},"routing":{"type":"string","enum":["LIMIT_ORDER"]},"permitData":{"$ref":"#/components/schemas/NullablePermit"}},"required":["routing","quote","permitData","requestId"]},"QuoteRequest":{"type":"object","properties":{"type":{"$ref":"#/components/schemas/TradeType"},"amount":{"type":"string"},"tokenInChainId":{"$ref":"#/components/schemas/ChainId"},"tokenOutChainId":{"$ref":"#/components/schemas/ChainId"},"tokenIn":{"type":"string"},"tokenOut":{"type":"string"},"swapper":{"$ref":"#/components/schemas/Address"},"slippageTolerance":{"description":"For **Classic** swaps, the slippage tolerance is the maximum amount the price can change between the time the transaction is submitted and the time it is executed. The slippage tolerance is represented as a percentage of the total value of the swap. \n\n Slippage tolerance works differently in **DutchLimit** swaps, it does not set a limit on the Spread in an order. See [here](https://uniswap-docs.readme.io/reference/faqs#why-do-the-uniswapx-quotes-have-more-slippage-than-the-tolerance-i-set) for more information. \n\n **NOTE**: slippage is in terms of trade type. If the trade type is `EXACT_INPUT`, then the slippage is in terms of the output token. If the trade type is `EXACT_OUTPUT`, then the slippage is in terms of the input token.","type":"number"},"autoSlippage":{"$ref":"#/components/schemas/AutoSlippage"},"routingPreference":{"$ref":"#/components/schemas/RoutingPreference"},"protocols":{"$ref":"#/components/schemas/Protocols"},"spreadOptimization":{"$ref":"#/components/schemas/SpreadOptimization"},"urgency":{"$ref":"#/components/schemas/Urgency"}},"required":["type","amount","tokenInChainId","tokenOutChainId","tokenIn","tokenOut","swapper"]},"LimitOrderQuoteRequest":{"type":"object","properties":{"swapper":{"$ref":"#/components/schemas/Address"},"limitPrice":{"type":"string"},"amount":{"type":"string"},"orderDeadline":{"type":"number"},"type":{"$ref":"#/components/schemas/TradeType"},"tokenIn":{"type":"string"},"tokenOut":{"type":"string"},"tokenInChainId":{"$ref":"#/components/schemas/ChainId"},"tokenOutChainId":{"$ref":"#/components/schemas/ChainId"}},"required":["swapper","type","amount","tokenIn","tokenOut","tokenInChainId","tokenOutChainId"]},"GetOrdersResponse":{"type":"object","properties":{"requestId":{"$ref":"#/components/schemas/RequestId"},"orders":{"type":"array","items":{"$ref":"#/components/schemas/UniswapXOrder"}},"cursor":{"type":"string"}},"required":["orders","requestId"]},"OrderResponse":{"type":"object","properties":{"requestId":{"$ref":"#/components/schemas/RequestId"},"orderId":{"type":"string"},"orderStatus":{"$ref":"#/components/schemas/OrderStatus"}},"required":["requestId","orderId","orderStatus"]},"OrderRequest":{"type":"object","properties":{"signature":{"type":"string","description":"The signed permit."},"quote":{"oneOf":[{"$ref":"#/components/schemas/DutchQuote"},{"$ref":"#/components/schemas/DutchQuoteV2"},{"$ref":"#/components/schemas/PriorityQuote"}]},"routing":{"$ref":"#/components/schemas/Routing"}},"required":["signature","quote"]},"Urgency":{"type":"string","enum":["normal","fast","urgent"],"description":"The urgency determines the urgency of the transaction. The default value is `urgent`.","default":"urgent"},"Protocols":{"type":"array","items":{"$ref":"#/components/schemas/ProtocolItems"},"description":"The protocols to use for the swap/order. If the `protocols` field is defined, then you can only set the `routingPreference` to `BEST_PRICE`"},"Err400":{"type":"object","properties":{"errorCode":{"default":"RequestValidationError","type":"string"},"detail":{"type":"string"}}},"Err401":{"type":"object","properties":{"errorCode":{"default":"UnauthorizedError","type":"string"},"detail":{"type":"string"}}},"Err404":{"type":"object","properties":{"errorCode":{"enum":["ResourceNotFound","QuoteAmountTooLowError"],"type":"string"},"detail":{"type":"string"}}},"Err429":{"type":"object","properties":{"errorCode":{"default":"Ratelimited","type":"string"},"detail":{"type":"string"}}},"Err500":{"type":"object","properties":{"errorCode":{"default":"InternalServerError","type":"string"},"detail":{"type":"string"}}},"Err504":{"type":"object","properties":{"errorCode":{"default":"Timeout","type":"string"},"detail":{"type":"string"}}},"ChainId":{"type":"number","enum":[1,10,56,137,8453,42161,81457,43114,42220,7777777,324,11155111,1301,480]},"OrderInput":{"type":"object","properties":{"token":{"type":"string"},"startAmount":{"type":"string"},"endAmount":{"type":"string"}},"required":["token"]},"OrderOutput":{"type":"object","properties":{"token":{"type":"string"},"startAmount":{"type":"string"},"endAmount":{"type":"string"},"isFeeOutput":{"type":"boolean"},"recipient":{"type":"string"}},"required":["token"]},"CosignerData":{"type":"object","properties":{"decayStartTime":{"type":"number"},"decayEndTime":{"type":"number"},"exclusiveFiller":{"type":"string"},"inputOverride":{"type":"string"},"outputOverrides":{"type":"array","items":{"type":"string"}}}},"SettledAmount":{"type":"object","properties":{"tokenOut":{"$ref":"#/components/schemas/Address"},"amountOut":{"type":"string"},"tokenIn":{"$ref":"#/components/schemas/Address"},"amountIn":{"type":"string"}}},"OrderType":{"type":"string","enum":["DutchLimit","Dutch","Dutch_V2"]},"OrderTypeQuery":{"type":"string","enum":["Dutch","Dutch_V2","Dutch_V1_V2","Limit","Priority"]},"UniswapXOrder":{"type":"object","properties":{"type":{"$ref":"#/components/schemas/OrderType"},"encodedOrder":{"type":"string"},"signature":{"type":"string"},"nonce":{"type":"string"},"orderStatus":{"$ref":"#/components/schemas/OrderStatus"},"orderId":{"type":"string"},"chainId":{"$ref":"#/components/schemas/ChainId"},"quoteId":{"type":"string"},"swapper":{"type":"string"},"txHash":{"type":"string"},"input":{"$ref":"#/components/schemas/OrderInput"},"outputs":{"type":"array","items":{"$ref":"#/components/schemas/OrderOutput"}},"settledAmounts":{"type":"array","items":{"$ref":"#/components/schemas/SettledAmount"}},"cosignature":{"type":"string"},"cosignerData":{"$ref":"#/components/schemas/CosignerData"}},"required":["encodedOrder","signature","nonce","orderId","orderStatus","chainId","type"]},"SortKey":{"type":"string","enum":["createdAt"]},"OrderId":{"type":"string"},"OrderIds":{"type":"string"},"OrderStatus":{"type":"string","enum":["open","expired","error","cancelled","filled","unverified","insufficient-funds"]},"Permit":{"type":"object","properties":{"domain":{"type":"object"},"values":{"type":"object"},"types":{"type":"object"}}},"TokenProject":{"type":"object","properties":{"logo":{"$ref":"#/components/schemas/TokenProjectLogo","nullable":true},"safetyLevel":{"$ref":"#/components/schemas/SafetyLevel"},"isSpam":{"type":"boolean"}},"required":["logo","safetyLevel","isSpam"]},"TokenProjectLogo":{"type":"object","properties":{"url":{"type":"string"}},"required":["url"]},"DutchInput":{"type":"object","properties":{"startAmount":{"type":"string"},"endAmount":{"type":"string"},"token":{"type":"string"}},"required":["startAmount","endAmount","type"]},"DutchOutput":{"type":"object","properties":{"startAmount":{"type":"string"},"endAmount":{"type":"string"},"token":{"type":"string"},"recipient":{"type":"string"}},"required":["startAmount","endAmount","token","recipient"]},"DutchOrderInfo":{"type":"object","properties":{"chainId":{"$ref":"#/components/schemas/ChainId"},"nonce":{"type":"string"},"reactor":{"type":"string"},"swapper":{"type":"string"},"deadline":{"type":"number"},"additionalValidationContract":{"type":"string"},"additionalValidationData":{"type":"string"},"decayStartTime":{"type":"number"},"decayEndTime":{"type":"number"},"exclusiveFiller":{"type":"string"},"exclusivityOverrideBps":{"type":"string"},"input":{"$ref":"#/components/schemas/DutchInput"},"outputs":{"type":"array","items":{"$ref":"#/components/schemas/DutchOutput"}}},"required":["chainId","nonce","reactor","swapper","deadline","validationContract","validationData","startTime","endTime","exclusiveFiller","exclusivityOverrideBps","input","outputs"]},"DutchOrderInfoV2":{"type":"object","properties":{"chainId":{"$ref":"#/components/schemas/ChainId"},"nonce":{"type":"string"},"reactor":{"type":"string"},"swapper":{"type":"string"},"deadline":{"type":"number"},"additionalValidationContract":{"type":"string"},"additionalValidationData":{"type":"string"},"input":{"$ref":"#/components/schemas/DutchInput"},"outputs":{"type":"array","items":{"$ref":"#/components/schemas/DutchOutput"}},"cosigner":{"$ref":"#/components/schemas/Address"}},"required":["chainId","nonce","reactor","swapper","deadline","validationContract","validationData","startTime","endTime","exclusiveFiller","exclusivityOverrideBps","input","outputs"]},"DutchQuote":{"type":"object","properties":{"encodedOrder":{"type":"string"},"orderId":{"type":"string"},"orderInfo":{"$ref":"#/components/schemas/DutchOrderInfo"},"portionBips":{"type":"number"},"portionAmount":{"type":"string"},"portionRecipient":{"$ref":"#/components/schemas/Address"},"quoteId":{"type":"string"},"slippageTolerance":{"type":"number"},"classicGasUseEstimateUSD":{"$ref":"#/components/schemas/ClassicGasUseEstimateUSD"}},"required":["encodedOrder","orderInfo","orderId"]},"DutchQuoteV2":{"type":"object","properties":{"encodedOrder":{"type":"string"},"orderId":{"type":"string"},"orderInfo":{"$ref":"#/components/schemas/DutchOrderInfoV2"},"portionBips":{"type":"number"},"portionAmount":{"type":"string"},"portionRecipient":{"$ref":"#/components/schemas/Address"},"quoteId":{"type":"string"},"slippageTolerance":{"type":"number"},"deadlineBufferSecs":{"type":"number"},"classicGasUseEstimateUSD":{"$ref":"#/components/schemas/ClassicGasUseEstimateUSD"}},"required":["encodedOrder","orderInfo","orderId"]},"PriorityInput":{"type":"object","properties":{"amount":{"type":"string"},"token":{"type":"string"},"mpsPerPriorityFeeWei":{"type":"string"}},"required":["amount","token","mpsPerPriorityFeeWei"]},"PriorityOutput":{"type":"object","properties":{"amount":{"type":"string"},"token":{"type":"string"},"recipient":{"type":"string"},"mpsPerPriorityFeeWei":{"type":"string"}},"required":["amount","token","recipient","mpsPerPriorityFeeWei"]},"PriorityOrderInfo":{"type":"object","properties":{"chainId":{"$ref":"#/components/schemas/ChainId"},"nonce":{"type":"string"},"reactor":{"type":"string"},"swapper":{"type":"string"},"deadline":{"type":"number"},"additionalValidationContract":{"type":"string"},"additionalValidationData":{"type":"string"},"auctionStartBlock":{"type":"string"},"baselinePriorityFeeWei":{"type":"string"},"input":{"$ref":"#/components/schemas/PriorityInput"},"outputs":{"type":"array","items":{"$ref":"#/components/schemas/PriorityOutput"}},"cosigner":{"$ref":"#/components/schemas/Address"}},"required":["chainId","nonce","reactor","swapper","deadline","validationContract","validationData","auctionStartBlock","baselinePriorityFeeWei","input","outputs","cosigner"]},"PriorityQuote":{"type":"object","properties":{"encodedOrder":{"type":"string"},"orderId":{"type":"string"},"orderInfo":{"$ref":"#/components/schemas/PriorityOrderInfo"},"portionBips":{"type":"number"},"portionAmount":{"type":"string"},"portionRecipient":{"$ref":"#/components/schemas/Address"},"quoteId":{"type":"string"},"slippageTolerance":{"type":"number"},"deadlineBufferSecs":{"type":"number"},"classicGasUseEstimateUSD":{"$ref":"#/components/schemas/ClassicGasUseEstimateUSD"},"expectedAmountIn":{"type":"string"},"expectedAmountOut":{"type":"string"}},"required":["encodedOrder","orderInfo","orderId"]},"BridgeQuote":{"type":"object","properties":{"quoteId":{"type":"string"},"chainId":{"$ref":"#/components/schemas/ChainId"},"destinationChainId":{"$ref":"#/components/schemas/ChainId"},"swapper":{"$ref":"#/components/schemas/Address"},"input":{"$ref":"#/components/schemas/ClassicInput"},"output":{"$ref":"#/components/schemas/ClassicOutput"},"tradeType":{"$ref":"#/components/schemas/TradeType"},"quoteTimestamp":{"type":"number"},"gasPrice":{"type":"string"},"maxFeePerGas":{"type":"string"},"maxPriorityFeePerGas":{"type":"string"},"gasFee":{"type":"string"},"gasUseEstimate":{"type":"string"},"gasFeeUSD":{"type":"string"},"portionBips":{"type":"number"},"portionAmount":{"type":"string"},"portionRecipient":{"$ref":"#/components/schemas/Address"},"estimatedFillTimeMs":{"type":"number"}}},"SafetyLevel":{"type":"string","enum":["BLOCKED","MEDIUM_WARNING","STRONG_WARNING","VERIFIED"]},"TradeType":{"type":"string","enum":["EXACT_INPUT","EXACT_OUTPUT"]},"Routing":{"type":"string","enum":["DUTCH_LIMIT","CLASSIC","DUTCH_V2","BRIDGE","LIMIT_ORDER","PRIORITY"]},"Quote":{"oneOf":[{"$ref":"#/components/schemas/DutchQuote"},{"$ref":"#/components/schemas/ClassicQuote"},{"$ref":"#/components/schemas/WrapUnwrapQuote"},{"$ref":"#/components/schemas/DutchQuoteV2"},{"$ref":"#/components/schemas/BridgeQuote"},{"$ref":"#/components/schemas/PriorityQuote"}]},"CheckApprovalLPRequest":{"type":"object","properties":{"protocol":{"$ref":"#/components/schemas/ProtocolItems"},"token0":{"$ref":"#/components/schemas/Address"},"token1":{"$ref":"#/components/schemas/Address"},"positionToken":{"$ref":"#/components/schemas/Address"},"chainId":{"$ref":"#/components/schemas/ChainId"},"walletAddress":{"$ref":"#/components/schemas/Address"},"amount0":{"type":"string"},"amount1":{"type":"string"},"positionAmount":{"type":"string"},"simulateTransaction":{"type":"boolean"}}},"CheckApprovalLPResponse":{"type":"object","properties":{"requestId":{"$ref":"#/components/schemas/RequestId"},"token0Approval":{"$ref":"#/components/schemas/TransactionRequest"},"token1Approval":{"$ref":"#/components/schemas/TransactionRequest"},"positionTokenApproval":{"$ref":"#/components/schemas/TransactionRequest"},"permitData":{"$ref":"#/components/schemas/NullablePermit"},"gasFeeToken0Approval":{"type":"string"},"gasFeeToken1Approval":{"type":"string"},"gasFeePositionTokenApproval":{"type":"string"}}},"ApprovalRequest":{"type":"object","properties":{"walletAddress":{"$ref":"#/components/schemas/Address"},"token":{"$ref":"#/components/schemas/Address"},"amount":{"$ref":"#/components/schemas/TokenAmount"},"chainId":{"$ref":"#/components/schemas/ChainId"},"urgency":{"$ref":"#/components/schemas/Urgency"},"includeGasInfo":{"type":"boolean","default":false},"tokenOut":{"$ref":"#/components/schemas/Address"},"tokenOutChainId":{"$ref":"#/components/schemas/ChainId"}},"required":["walletAddress","token","amount"]},"ApprovalResponse":{"type":"object","properties":{"requestId":{"$ref":"#/components/schemas/RequestId"},"approval":{"$ref":"#/components/schemas/TransactionRequest"},"cancel":{"$ref":"#/components/schemas/TransactionRequest"},"gasFee":{"type":"string"},"cancelGasFee":{"type":"string"}},"required":["requestId","approval","cancel"]},"ClassicQuote":{"type":"object","properties":{"input":{"$ref":"#/components/schemas/ClassicInput"},"output":{"$ref":"#/components/schemas/ClassicOutput"},"swapper":{"$ref":"#/components/schemas/Address"},"chainId":{"$ref":"#/components/schemas/ChainId"},"slippage":{"type":"number"},"tradeType":{"$ref":"#/components/schemas/TradeType"},"gasFee":{"type":"string","description":"The gas fee in terms of wei. It does NOT include the additional gas for token approvals."},"gasFeeUSD":{"type":"string","description":"The gas fee in terms of USD. It does NOT include the additional gas for token approvals."},"gasFeeQuote":{"type":"string","description":"The gas fee in terms of the quoted currency. It does NOT include the additional gas for token approvals."},"route":{"type":"array","items":{"type":"array","items":{"oneOf":[{"$ref":"#/components/schemas/V3PoolInRoute"},{"$ref":"#/components/schemas/V2PoolInRoute"},{"$ref":"#/components/schemas/V4PoolInRoute"}]}}},"portionBips":{"type":"number","description":"The portion of the swap that will be taken as a fee. The fee will be taken from the output token."},"portionAmount":{"type":"string","description":"The amount of the swap that will be taken as a fee. The fee will be taken from the output token."},"portionRecipient":{"$ref":"#/components/schemas/Address"},"routeString":{"type":"string","description":"The route in string format."},"quoteId":{"type":"string","description":"The quote id. Used for analytics purposes."},"gasUseEstimate":{"type":"string","description":"The estimated gas use. It does NOT include the additional gas for token approvals."},"blockNumber":{"type":"string","description":"The current block number."},"gasPrice":{"type":"string","description":"The gas price in terms of wei for pre EIP1559 transactions."},"maxFeePerGas":{"type":"string","description":"The maximum fee per gas in terms of wei for EIP1559 transactions."},"maxPriorityFeePerGas":{"type":"string","description":"The maximum priority fee per gas in terms of wei for EIP1559 transactions."},"txFailureReasons":{"type":"array","items":{"$ref":"#/components/schemas/TransactionFailureReason"}},"priceImpact":{"type":"number","description":"The impact the trade has on the market price of the pool, between 0-100 percent"}}},"WrapUnwrapQuote":{"type":"object","properties":{"swapper":{"$ref":"#/components/schemas/Address"},"input":{"$ref":"#/components/schemas/ClassicInput"},"output":{"$ref":"#/components/schemas/ClassicOutput"},"chainId":{"$ref":"#/components/schemas/ChainId"},"tradeType":{"$ref":"#/components/schemas/TradeType"},"gasFee":{"type":"string","description":"The gas fee in terms of wei."},"gasFeeUSD":{"type":"string","description":"The gas fee in terms of USD."},"gasFeeQuote":{"type":"string","description":"The gas fee in terms of the quoted currency."},"gasUseEstimate":{"type":"string","description":"The estimated gas use."},"gasPrice":{"type":"string","description":"The gas price in terms of wei for pre EIP1559 transactions."},"maxFeePerGas":{"type":"string","description":"The maximum fee per gas in terms of wei for EIP1559 transactions."},"maxPriorityFeePerGas":{"type":"string","description":"The maximum priority fee per gas in terms of wei for EIP1559 transactions."}}},"TokenInRoute":{"type":"object","properties":{"address":{"$ref":"#/components/schemas/Address"},"chainId":{"$ref":"#/components/schemas/ChainId"},"symbol":{"type":"string"},"decimals":{"type":"string"},"buyFeeBps":{"type":"string"},"sellFeeBps":{"type":"string"}}},"V2Reserve":{"type":"object","properties":{"token":{"$ref":"#/components/schemas/TokenInRoute"},"quotient":{"type":"string"}}},"V2PoolInRoute":{"type":"object","properties":{"type":{"type":"string","default":"v2-pool"},"address":{"$ref":"#/components/schemas/Address"},"tokenIn":{"$ref":"#/components/schemas/TokenInRoute"},"tokenOut":{"$ref":"#/components/schemas/TokenInRoute"},"reserve0":{"$ref":"#/components/schemas/V2Reserve"},"reserve1":{"$ref":"#/components/schemas/V2Reserve"},"amountIn":{"type":"string"},"amountOut":{"type":"string"}}},"V3PoolInRoute":{"type":"object","properties":{"type":{"type":"string","default":"v3-pool"},"address":{"$ref":"#/components/schemas/Address"},"tokenIn":{"$ref":"#/components/schemas/TokenInRoute"},"tokenOut":{"$ref":"#/components/schemas/TokenInRoute"},"sqrtRatioX96":{"type":"string"},"liquidity":{"type":"string"},"tickCurrent":{"type":"string"},"fee":{"type":"string"},"amountIn":{"type":"string"},"amountOut":{"type":"string"}}},"V4PoolInRoute":{"type":"object","properties":{"type":{"type":"string","default":"v4-pool"},"address":{"$ref":"#/components/schemas/Address"},"tokenIn":{"$ref":"#/components/schemas/TokenInRoute"},"tokenOut":{"$ref":"#/components/schemas/TokenInRoute"},"sqrtRatioX96":{"type":"string"},"liquidity":{"type":"string"},"tickCurrent":{"type":"string"},"fee":{"type":"string"},"tickSpacing":{"type":"string"},"hooks":{"type":"string"},"amountIn":{"type":"string"},"amountOut":{"type":"string"}},"required":["type","address","tokenIn","tokenOut","sqrtRatioX96","liquidity","tickCurrent","fee","tickSpacing","hooks"]},"TransactionHash":{"type":"string","pattern":"^(0x)?[0-9a-fA-F]{64}$"},"ClassicInput":{"type":"object","properties":{"token":{"$ref":"#/components/schemas/Address"},"amount":{"type":"string"}}},"ClassicOutput":{"type":"object","properties":{"token":{"$ref":"#/components/schemas/Address"},"amount":{"type":"string"},"recipient":{"$ref":"#/components/schemas/Address"}}},"RequestId":{"type":"string"},"SpreadOptimization":{"type":"string","enum":["EXECUTION","PRICE"],"description":"For **Dutch Limit** orders only. When set to `EXECUTION`, quotes optimize for looser spreads at higher fill rates. When set to `PRICE`, quotes optimize for tighter spreads at lower fill rates","default":"EXECUTION"},"AutoSlippage":{"type":"string","enum":["DEFAULT"],"description":"For **Classic** swaps only. The auto slippage strategy to employ. If auto slippage is not defined then we don't compute it. If the auto slippage strategy is `DEFAULT`, then the swap will use the default slippage tolerance computation. You cannot define auto slippage and slippage tolerance at the same time. \n\n **NOTE**: slippage is in terms of trade type. If the trade type is `EXACT_INPUT`, then the slippage is in terms of the output token. If the trade type is `EXACT_OUTPUT`, then the slippage is in terms of the input token.","default":"undefined"},"RoutingPreference":{"type":"string","description":"The routing preference determines which protocol to use for the swap. If the routing preference is `UNISWAPX`, then the swap will be routed through the UniswapX Dutch Auction Protocol. If the routing preference is `CLASSIC`, then the swap will be routed through the Classic Protocol. If the routing preference is `BEST_PRICE`, then the swap will be routed through the protocol that provides the best price. When `UNIXWAPX_V2` is passed, the swap will be routed through the UniswapX V2 Dutch Auction Protocol. When `V3_ONLY` is passed, the swap will be routed ONLY through the Uniswap V3 Protocol. When `V2_ONLY` is passed, the swap will be routed ONLY through the Uniswap V2 Protocol.","enum":["CLASSIC","UNISWAPX","BEST_PRICE","BEST_PRICE_V2","UNISWAPX_V2","V3_ONLY","V2_ONLY"],"default":"BEST_PRICE"},"ProtocolItems":{"type":"string","enum":["V2","V3","V4","UNISWAPX","UNISWAPX_V2","PRIORITY"]},"TransactionRequest":{"type":"object","properties":{"to":{"$ref":"#/components/schemas/Address"},"from":{"$ref":"#/components/schemas/Address"},"data":{"type":"string","description":"The calldata for the transaction."},"value":{"type":"string","description":"The value of the transaction in terms of wei in hex format."},"gasLimit":{"type":"string"},"chainId":{"type":"integer"},"maxFeePerGas":{"type":"string"},"maxPriorityFeePerGas":{"type":"string"},"gasPrice":{"type":"string"}},"required":["to","from","data","value","chainId"]},"TransactionFailureReason":{"type":"string","enum":["SIMULATION_ERROR","UNSUPPORTED_SIMULATION"]},"SwapSafetyMode":{"type":"string","enum":["SAFE"],"description":"The safety mode determines the safety level of the swap. If the safety mode is `SAFE`, then the swap will include a SWEEP for the native token."},"IndicativeQuoteRequest":{"type":"object","properties":{"type":{"$ref":"#/components/schemas/TradeType"},"amount":{"type":"string"},"tokenInChainId":{"$ref":"#/components/schemas/ChainId"},"tokenOutChainId":{"$ref":"#/components/schemas/ChainId"},"tokenIn":{"type":"string"},"tokenOut":{"type":"string"}},"required":["type","amount","tokenInChainId","tokenOutChainId","tokenIn","tokenOut"]},"IndicativeQuoteResponse":{"type":"object","properties":{"requestId":{"$ref":"#/components/schemas/RequestId"},"input":{"$ref":"#/components/schemas/IndicativeQuoteToken"},"output":{"$ref":"#/components/schemas/IndicativeQuoteToken"},"type":{"$ref":"#/components/schemas/TradeType"}},"required":["requestId","input","output","type"]},"CreateLPPositionRequest":{"type":"object","properties":{"protocol":{"$ref":"#/components/schemas/ProtocolItems"},"position":{"$ref":"#/components/schemas/Position"},"walletAddress":{"$ref":"#/components/schemas/Address"},"chainId":{"$ref":"#/components/schemas/ChainId"},"initialPrice":{"type":"string"},"poolLiquidity":{"type":"string"},"currentTick":{"type":"number"},"sqrtRatioX96":{"type":"string"},"amount0":{"type":"string"},"amount1":{"type":"string"},"slippageTolerance":{"type":"string"},"deadline":{"type":"number"},"signature":{"type":"string","description":"The signed permit."},"batchPermitData":{"allOf":[{"$ref":"#/components/schemas/Permit"}]},"simulateTransaction":{"type":"boolean"}}},"CreateLPPositionResponse":{"type":"object","properties":{"requestId":{"$ref":"#/components/schemas/RequestId"},"create":{"$ref":"#/components/schemas/TransactionRequest"},"gasFee":{"type":"string"}}},"IncreaseLPPositionRequest":{"type":"object","properties":{"protocol":{"$ref":"#/components/schemas/ProtocolItems"},"tokenId":{"type":"number"},"position":{"$ref":"#/components/schemas/Position"},"poolLiquidity":{"type":"string"},"currentTick":{"type":"number"},"sqrtRatioX96":{"type":"string"},"walletAddress":{"$ref":"#/components/schemas/Address"},"chainId":{"$ref":"#/components/schemas/ChainId"},"amount0":{"type":"string"},"amount1":{"type":"string"},"slippageTolerance":{"type":"string"},"deadline":{"type":"number"},"signature":{"type":"string","description":"The signed permit."},"batchPermitData":{"allOf":[{"$ref":"#/components/schemas/Permit"}]},"simulateTransaction":{"type":"boolean"}}},"IncreaseLPPositionResponse":{"type":"object","properties":{"requestId":{"$ref":"#/components/schemas/RequestId"},"increase":{"$ref":"#/components/schemas/TransactionRequest"},"gasFee":{"type":"string"}}},"DecreaseLPPositionRequest":{"type":"object","properties":{"protocol":{"$ref":"#/components/schemas/ProtocolItems"},"tokenId":{"type":"number"},"position":{"$ref":"#/components/schemas/Position"},"walletAddress":{"$ref":"#/components/schemas/Address"},"chainId":{"$ref":"#/components/schemas/ChainId"},"liquidityPercentageToDecrease":{"type":"number"},"liquidity0":{"type":"string"},"liquidity1":{"type":"string"},"slippageTolerance":{"type":"string"},"poolLiquidity":{"type":"string"},"currentTick":{"type":"number"},"sqrtRatioX96":{"type":"string"},"positionLiquidity":{"type":"string"},"expectedTokenOwed0RawAmount":{"type":"string"},"expectedTokenOwed1RawAmount":{"type":"string"},"collectAsWETH":{"type":"boolean"},"deadline":{"type":"number"},"simulateTransaction":{"type":"boolean"}}},"DecreaseLPPositionResponse":{"type":"object","properties":{"requestId":{"$ref":"#/components/schemas/RequestId"},"decrease":{"$ref":"#/components/schemas/TransactionRequest"},"gasFee":{"type":"string"}}},"ClaimLPFeesRequest":{"type":"object","properties":{"protocol":{"$ref":"#/components/schemas/ProtocolItems"},"tokenId":{"type":"number"},"position":{"$ref":"#/components/schemas/Position"},"walletAddress":{"$ref":"#/components/schemas/Address"},"chainId":{"$ref":"#/components/schemas/ChainId"},"expectedTokenOwed0RawAmount":{"type":"string"},"expectedTokenOwed1RawAmount":{"type":"string"},"collectAsWETH":{"type":"boolean"},"simulateTransaction":{"type":"boolean"}}},"ClaimLPFeesResponse":{"type":"object","properties":{"requestId":{"$ref":"#/components/schemas/RequestId"},"claim":{"$ref":"#/components/schemas/TransactionRequest"},"gasFee":{"type":"string"}}},"MigrateLPPositionRequest":{"type":"object","properties":{"tokenId":{"type":"number"},"walletAddress":{"$ref":"#/components/schemas/Address"},"chainId":{"$ref":"#/components/schemas/ChainId"},"inputProtocol":{"$ref":"#/components/schemas/ProtocolItems"},"inputPosition":{"$ref":"#/components/schemas/Position"},"inputPoolLiquidity":{"type":"string"},"inputCurrentTick":{"type":"number"},"inputSqrtRatioX96":{"type":"string"},"inputPositionLiquidity":{"type":"string"},"signature":{"type":"string"},"amount0":{"type":"string"},"amount1":{"type":"string"},"outputProtocol":{"$ref":"#/components/schemas/ProtocolItems"},"outputPosition":{"$ref":"#/components/schemas/Position"},"initialPrice":{"type":"string"},"outputPoolLiquidity":{"type":"string"},"outputCurrentTick":{"type":"number"},"outputSqrtRatioX96":{"type":"string"},"expectedTokenOwed0RawAmount":{"type":"string"},"expectedTokenOwed1RawAmount":{"type":"string"},"slippageTolerance":{"type":"number"},"deadline":{"type":"number"},"signatureDeadline":{"type":"number"},"simulateTransaction":{"type":"boolean","default":false}},"required":["tokenId","chainId","walletAddress","inputProtocol","inputPosition","inputPoolLiquidity","inputCurrentTick","inputSqrtRatioX96","inputPositionLiquidity","amount0","amount1","outputProtocol","outputPosition","expectedTokenOwed0RawAmount","expectedTokenOwed1RawAmount"]},"MigrateLPPositionResponse":{"type":"object","properties":{"requestId":{"$ref":"#/components/schemas/RequestId"},"migrate":{"$ref":"#/components/schemas/TransactionRequest"},"gasFee":{"type":"string"}}},"IndicativeQuoteToken":{"type":"object","properties":{"amount":{"type":"string"},"chainId":{"$ref":"#/components/schemas/ChainId"},"token":{"$ref":"#/components/schemas/Address"}}}},"parameters":{"universalRouterVersionHeader":{"name":"x-universal-router-version","in":"header","description":"The version of the Universal Router to use for the swap journey. *MUST* be consistent throughout the API calls.","required":false,"schema":{"$ref":"#/components/schemas/UniversalRouterVersion"}},"addressParam":{"name":"address","in":"path","schema":{"$ref":"#/components/schemas/Address"},"required":true},"tokenIdParam":{"name":"tokenId","in":"path","schema":{"type":"string"},"required":true},"cursorParam":{"name":"cursor","in":"query","schema":{"type":"string"},"required":false},"limitParam":{"name":"limit","in":"query","schema":{"type":"number"},"required":false},"chainIdParam":{"name":"chainId","in":"query","schema":{"$ref":"#/components/schemas/ChainId"},"required":false},"bridgeTokenInChainIdParam":{"name":"tokenInChainId","in":"query","schema":{"$ref":"#/components/schemas/ChainId"},"required":false},"bridgeTokenOutChainIdParam":{"name":"tokenOutChainId","in":"query","schema":{"$ref":"#/components/schemas/ChainId"},"required":false},"tokenInParam":{"name":"tokenIn","in":"query","schema":{"$ref":"#/components/schemas/Address"},"required":false},"tokenOutParam":{"name":"tokenOut","in":"query","schema":{"$ref":"#/components/schemas/Address"},"required":false},"addressPathParam":{"name":"address","in":"query","schema":{"$ref":"#/components/schemas/Address"},"required":false},"orderStatusParam":{"name":"orderStatus","in":"query","description":"Filter by order status.","required":false,"schema":{"$ref":"#/components/schemas/OrderStatus"}},"orderTypeParam":{"name":"orderType","in":"query","description":"The default orderType is Dutch_V1_V2 and will grab both Dutch and Dutch_V2 orders.","required":false,"schema":{"$ref":"#/components/schemas/OrderTypeQuery"}},"orderIdParam":{"name":"orderId","in":"query","required":false,"schema":{"$ref":"#/components/schemas/OrderId"}},"orderIdsParam":{"name":"orderIds","in":"query","required":false,"description":"ids split by commas","schema":{"$ref":"#/components/schemas/OrderIds"}},"swapperParam":{"name":"swapper","in":"query","description":"Filter by swapper address.","required":false,"schema":{"$ref":"#/components/schemas/Address"}},"fillerParam":{"name":"filler","in":"query","description":"Filter by filler address.","required":false,"schema":{"$ref":"#/components/schemas/Address"}},"sortKeyParam":{"name":"sortKey","in":"query","description":"Order the query results by the sort key.","required":false,"schema":{"$ref":"#/components/schemas/SortKey"}},"sortParam":{"name":"sort","in":"query","description":"Sort query. For example: `sort=gt(UNIX_TIMESTAMP)`, `sort=between(1675872827, 1675872930)`, or `lt(1675872930)`.","required":false,"schema":{"type":"string"}},"descParam":{"description":"Sort query results by sortKey in descending order.","name":"desc","in":"query","required":false,"schema":{"type":"string"}},"transactionHashesParam":{"description":"The transaction hashes.","name":"txHashes","in":"query","required":true,"style":"form","explode":false,"schema":{"type":"array","items":{"$ref":"#/components/schemas/TransactionHash"}}}},"securitySchemes":{"apiKey":{"type":"apiKey","in":"header","name":"x-api-key"}}},"security":[{"apiKey":[]}]} \ No newline at end of file diff --git a/packages/uniswap/src/data/types.ts b/packages/uniswap/src/data/types.ts index c0195ef55e4..4b9a7db47d2 100644 --- a/packages/uniswap/src/data/types.ts +++ b/packages/uniswap/src/data/types.ts @@ -6,3 +6,9 @@ export type GqlResult = Pick, 'data' | 'loading'> & refetch?: () => void // TODO: [MOB-222] figure out the proper type for this from a QueryResult error?: ApolloError | Error } + +export enum SpamCode { + LOW = 0, // same as isSpam = false on TokenProject + MEDIUM = 1, // same as isSpam = true on TokenProject + HIGH = 2, // has a URL in token name +} diff --git a/packages/uniswap/src/features/address/ExplorerView.tsx b/packages/uniswap/src/features/address/ExplorerView.tsx index beddbadf5d3..e62c6132cd9 100644 --- a/packages/uniswap/src/features/address/ExplorerView.tsx +++ b/packages/uniswap/src/features/address/ExplorerView.tsx @@ -1,25 +1,45 @@ import { SharedEventName } from '@uniswap/analytics-events' import { Currency } from '@uniswap/sdk-core' +import { useState } from 'react' +import { useDispatch } from 'react-redux' import { Anchor, Flex, Text, TouchableArea } from 'ui/src' +import { CheckCircleFilled } from 'ui/src/components/icons/CheckCircleFilled' import { CopyAlt } from 'ui/src/components/icons/CopyAlt' -import { ExternalLink } from 'ui/src/components/icons/ExternalLink' +import { MicroConfirmation } from 'uniswap/src/components/MicroConfirmation' +import { pushNotification } from 'uniswap/src/features/notifications/slice' +import { AppNotificationType, CopyNotificationType } from 'uniswap/src/features/notifications/types' import { ElementName } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import { WarningModalInfoContainer } from 'uniswap/src/features/tokens/TokenWarningModal' +import { useTranslation } from 'uniswap/src/i18n' import { setClipboard } from 'uniswap/src/utils/clipboard' import { ExplorerDataType, getExplorerLink } from 'uniswap/src/utils/linking' +import { isInterface } from 'utilities/src/platform' export function ExplorerView({ currency, modalName }: { currency: Currency; modalName: string }): JSX.Element | null { + const { t } = useTranslation() + const dispatch = useDispatch() + + const [showTooltip, setShowTooltip] = useState(false) if (currency) { const explorerLink = getExplorerLink( currency.chainId, currency.isToken ? currency.address : '', currency.isToken ? ExplorerDataType.TOKEN : ExplorerDataType.NATIVE, ) - const onPressCopyAddress = async (): Promise => { await setClipboard(explorerLink) - // TODO(WALL-4688): should we dispatch(pushNotification()) here on mobile/ext, tooltip on interface? + + if (isInterface) { + setShowTooltip(true) + setTimeout(() => { + setShowTooltip(false) + }, 1000) + } else { + dispatch( + pushNotification({ type: AppNotificationType.Copied, copyType: CopyNotificationType.BlockExplorerUrl }), + ) + } sendAnalyticsEvent(SharedEventName.ELEMENT_CLICKED, { element: ElementName.Copy, @@ -35,15 +55,19 @@ export function ExplorerView({ currency, modalName }: { currency: Currency; moda {explorerLink} - - - - - - + } + showTooltip={showTooltip} + trigger={ + + + + } + /> ) } else { diff --git a/packages/uniswap/src/features/bridging/constants.ts b/packages/uniswap/src/features/bridging/constants.ts new file mode 100644 index 00000000000..2c31fbd5fa2 --- /dev/null +++ b/packages/uniswap/src/features/bridging/constants.ts @@ -0,0 +1,84 @@ +import { UNIVERSE_CHAIN_INFO } from 'uniswap/src/constants/chains' +import { UniverseChainId } from 'uniswap/src/types/chains' +import { extractBaseUrl } from 'utilities/src/format/urls' + +/* + * Common bridging dapp urls + */ +const ACROSS_DAPP_URL = 'https://app.across.to' +const BUNGEE_DAPP_URL = 'https://www.bungee.exchange' +const JUMPER_DAPP_URL = 'https://jumper.exchange' +const RANGO_DAPP_URL = 'https://app.rango.exchange' +const DEBRIDGE_DAPP_URL = 'https://app.debridge.finance' +const SUPERBRIDGE_DAPP_URL = 'https://superbridge.app' +const BRIDGG_DAPP_URL = 'https://www.brid.gg' +const STARGATE_DAPP_URL = 'https://stargate.finance' +const CCTP_DAPP_URL = 'https://www.cctp.io' +const ORBITER_DAPP_URL = 'https://www.orbiter.finance' +const SYNAPSE_DAPP_URL = 'https://synapseprotocol.com' +const POLYGON_DAPP_URL = 'https://portal.polygon.technology' +const ARBITRUM_DAPP_URL = 'https://bridge.arbitrum.io' +const ZKSYNC_DAPP_URL = 'https://portal.zksync.io' +const HOP_DAPP_URL = 'https://app.hop.exchange' +const ZKBRIDGE_DAPP_URL = 'https://www.zkbridge.com' +const ALLBRIDGE_DAPP_URL = 'https://core.allbridge.io' +const CROSSCURVE_DAPP_URL = 'https://app.crosscurve.fi' +const SQUID_DAPP_URL = 'https://app.squidrouter.com' +const RHINO_DAPP_URL = 'https://app.rhino.fi' +const ROUTERNITRO_DAPP_URL = 'https://app.routernitro.com' +const CONNEXT_DAPP_URL = 'https://bridge.connext.network' +const SATELLITE_DAPP_URL = 'https://satellite.money' +const OWLTO_DAPP_URL = 'https://owlto.finance' +const XY_DAPP_URL = 'https://app.xy.finance' +const CELER_DAPP_URL = 'https://cbridge.celer.network' +const PORTAL_DAPP_URL = 'https://portalbridge.com' + +export const BRIDGING_DAPP_URLS = [ + ACROSS_DAPP_URL, + BUNGEE_DAPP_URL, + JUMPER_DAPP_URL, + RANGO_DAPP_URL, + DEBRIDGE_DAPP_URL, + SUPERBRIDGE_DAPP_URL, + BRIDGG_DAPP_URL, + STARGATE_DAPP_URL, + CCTP_DAPP_URL, + ORBITER_DAPP_URL, + SYNAPSE_DAPP_URL, + POLYGON_DAPP_URL, + ARBITRUM_DAPP_URL, + ZKSYNC_DAPP_URL, + HOP_DAPP_URL, + ZKBRIDGE_DAPP_URL, + ALLBRIDGE_DAPP_URL, + CROSSCURVE_DAPP_URL, + SQUID_DAPP_URL, + RHINO_DAPP_URL, + ROUTERNITRO_DAPP_URL, + CONNEXT_DAPP_URL, + SATELLITE_DAPP_URL, + OWLTO_DAPP_URL, + XY_DAPP_URL, + CELER_DAPP_URL, + PORTAL_DAPP_URL, +] + +export function getCanonicalBridgingDappUrls(chainIds: UniverseChainId[]): string[] { + const canonicalUrls = chainIds + .map((chainId) => { + const chainInfo = UNIVERSE_CHAIN_INFO[chainId] + return chainInfo?.bridge ? extractBaseUrl(chainInfo.bridge) : undefined + }) + .filter((url): url is string => url !== undefined) + + return [...new Set(canonicalUrls)] // Remove duplicates +} + +/* + * Combines both canonical and non-canonical bridging dapp urls + */ +export function getBridgingDappUrls(chainIds: UniverseChainId[]): string[] { + const canonicalUrls = getCanonicalBridgingDappUrls(chainIds) + const nonCanonicalUrls = BRIDGING_DAPP_URLS.filter((url) => !canonicalUrls.includes(url)) + return [...canonicalUrls, ...nonCanonicalUrls] +} diff --git a/packages/uniswap/src/features/bridging/hooks/chains.ts b/packages/uniswap/src/features/bridging/hooks/chains.ts index b69cbfc7528..e894266386c 100644 --- a/packages/uniswap/src/features/bridging/hooks/chains.ts +++ b/packages/uniswap/src/features/bridging/hooks/chains.ts @@ -1,6 +1,7 @@ import { useMemo } from 'react' import { useTradingApiSwappableTokensQuery } from 'uniswap/src/data/apiClients/tradingApi/useTradingApiSwappableTokensQuery' import { ChainId } from 'uniswap/src/data/tradingApi/__generated__' +import { toSupportedChainId } from 'uniswap/src/features/chains/utils' import { NATIVE_ADDRESS_FOR_TRADING_API, toTradingApiSupportedChainId, @@ -36,3 +37,23 @@ export function useIsBridgingChain(chainId: UniverseChainId): boolean { const chainIdForTradingApi = toTradingApiSupportedChainId(chainId) return chainIdForTradingApi !== undefined && chainSet.has(chainIdForTradingApi) } + +export function useBridgingSupportedChainIds(): UniverseChainId[] { + const { data: bridgingTokens } = useTradingApiSwappableTokensQuery({ + params: { + tokenIn: NATIVE_ADDRESS_FOR_TRADING_API, + tokenInChainId: ChainId._1, + }, + }) + + const chainSet = useMemo( + () => + new Set( + bridgingTokens?.tokens + .map((t) => toSupportedChainId(t.chainId)) + .filter((chainId): chainId is UniverseChainId => chainId !== null), + ), + [bridgingTokens], + ) + return Array.from(chainSet) +} diff --git a/packages/uniswap/src/features/chains/utils.ts b/packages/uniswap/src/features/chains/utils.ts index 05bb3d8dea1..ce0cc8873ed 100644 --- a/packages/uniswap/src/features/chains/utils.ts +++ b/packages/uniswap/src/features/chains/utils.ts @@ -8,8 +8,6 @@ import { } from 'uniswap/src/constants/chains' import { PollingInterval } from 'uniswap/src/constants/misc' import { Chain } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' -import { FeatureFlags } from 'uniswap/src/features/gating/flags' -import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { COMBINED_CHAIN_IDS, InterfaceGqlChain, @@ -189,13 +187,11 @@ export function toUniswapWebAppLink(chainId: UniverseChainId): string | null { } } -type ActiveChainIdFeatureFlags = UniverseChainId.WorldChain - export function filterChainIdsByFeatureFlag(featureFlaggedChainIds: { - [UniverseChainId.WorldChain]: boolean + [key in UniverseChainId]?: boolean }): UniverseChainId[] { return COMBINED_CHAIN_IDS.filter((chainId) => { - return featureFlaggedChainIds[chainId as ActiveChainIdFeatureFlags] ?? true + return featureFlaggedChainIds[chainId] ?? true }) } @@ -205,15 +201,7 @@ export function useFeatureFlaggedChainIds(): UniverseChainId[] { // Example: [ChainId.BLAST]: useFeatureFlag(FeatureFlags.BLAST) // IMPORTANT: Don't forget to also update getEnabledChainIdsSaga - const worldChainEnabled = useFeatureFlag(FeatureFlags.WorldChain) - - return useMemo( - () => - filterChainIdsByFeatureFlag({ - [UniverseChainId.WorldChain]: worldChainEnabled, - }), - [worldChainEnabled], - ) + return useMemo(() => filterChainIdsByFeatureFlag({}), []) } export function getEnabledChains({ diff --git a/packages/uniswap/src/features/dataApi/balances.ts b/packages/uniswap/src/features/dataApi/balances.ts index ba009858da3..a552fd522af 100644 --- a/packages/uniswap/src/features/dataApi/balances.ts +++ b/packages/uniswap/src/features/dataApi/balances.ts @@ -1,4 +1,6 @@ +/* eslint-disable max-lines */ import { NetworkStatus, Reference, useApolloClient, WatchQueryFetchPolicy } from '@apollo/client' +import isEqual from 'lodash/isEqual' import { useCallback, useMemo } from 'react' import { PollingInterval } from 'uniswap/src/constants/misc' import { @@ -9,12 +11,14 @@ import { PortfolioValueModifier, usePortfolioBalancesQuery, } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' -import { GqlResult } from 'uniswap/src/data/types' +import { GqlResult, SpamCode } from 'uniswap/src/data/types' import { fromGraphQLChain } from 'uniswap/src/features/chains/utils' -import { CurrencyInfo, PortfolioBalance } from 'uniswap/src/features/dataApi/types' +import { PortfolioBalance } from 'uniswap/src/features/dataApi/types' import { buildCurrency, + buildCurrencyInfo, currencyIdToContractInput, + getCurrencySafetyInfo, sortByName, usePersistedError, } from 'uniswap/src/features/dataApi/utils' @@ -54,7 +58,6 @@ export type PortfolioCacheUpdater = (hidden: boolean, portfolioBalance?: Portfol * we don't need to duplicate the polling interval when token selector is open * @param onCompleted * @param fetchPolicy - * @returns */ export function usePortfolioBalances({ address, @@ -100,6 +103,10 @@ export function usePortfolioBalances({ const byId: Record = {} balancesForAddress.forEach((balance) => { + if (!balance) { + return + } + const { __typename: tokenBalanceType, id: tokenBalanceId, @@ -108,46 +115,51 @@ export function usePortfolioBalances({ tokenProjectMarket, quantity, isHidden, - } = balance || {} - const { name, address: tokenAddress, chain, decimals, symbol, project } = token || {} - const { logoUrl, isSpam, safetyLevel } = project || {} - const chainId = fromGraphQLChain(chain) + } = balance // require all of these fields to be defined - if (!balance || !quantity || !token) { + if (!quantity || !token) { return } + const { name, address: tokenAddress, chain, decimals, symbol, project, feeData, protectionInfo } = token + const { logoUrl, isSpam, safetyLevel, spamCode } = project || {} + const chainId = fromGraphQLChain(chain) + const currency = buildCurrency({ chainId, address: tokenAddress, decimals, symbol, name, + buyFeeBps: feeData?.buyFeeBps, + sellFeeBps: feeData?.sellFeeBps, }) - if (!currency) { return } const id = currencyId(currency) - const currencyInfo: CurrencyInfo = { + const currencyInfo = buildCurrencyInfo({ currency, - currencyId: currencyId(currency), + currencyId: id, logoUrl, isSpam, safetyLevel, - } + safetyInfo: getCurrencySafetyInfo(safetyLevel, protectionInfo), + spamCode, + }) - const portfolioBalance: PortfolioBalance = { + const portfolioBalance = buildPortfolioBalance({ + id: tokenBalanceId, cacheId: `${tokenBalanceType}:${tokenBalanceId}`, quantity, balanceUSD: denominatedValue?.value, currencyInfo, relativeChange24: tokenProjectMarket?.relativeChange24?.value, isHidden, - } + }) byId[id] = portfolioBalance }) @@ -169,6 +181,20 @@ export function usePortfolioBalances({ } } +const PORTFOLIO_BALANCE_CACHE = new Map() + +function buildPortfolioBalance(args: PortfolioBalance): PortfolioBalance { + const cachedPortfolioBalance = PORTFOLIO_BALANCE_CACHE.get(args.cacheId) + + if (cachedPortfolioBalance && isEqual(cachedPortfolioBalance, args)) { + // This allows us to better memoize components that use a `portfolioBalance` as a dependency. + return cachedPortfolioBalance + } + + PORTFOLIO_BALANCE_CACHE.set(args.cacheId, args) + return args +} + export function usePortfolioTotalValue({ address, pollInterval, @@ -309,6 +335,8 @@ export function useTokenBalancesGroupedByVisibility({ shownTokens: PortfolioBalance[] | undefined hiddenTokens: PortfolioBalance[] | undefined } { + const { isTestnetModeEnabled } = useEnabledChains() + return useMemo(() => { if (!balancesById) { return { shownTokens: undefined, hiddenTokens: undefined } @@ -319,7 +347,11 @@ export function useTokenBalancesGroupedByVisibility({ hidden: PortfolioBalance[] }>( (acc, balance) => { - if (balance.isHidden) { + const isTokenHidden = isTestnetModeEnabled + ? (balance.currencyInfo.spamCode || SpamCode.LOW) >= SpamCode.HIGH + : balance.isHidden + + if (isTokenHidden) { acc.hidden.push(balance) } else { acc.shown.push(balance) @@ -332,7 +364,7 @@ export function useTokenBalancesGroupedByVisibility({ shownTokens: shown.length ? shown : undefined, hiddenTokens: hidden.length ? hidden : undefined, } - }, [balancesById]) + }, [balancesById, isTestnetModeEnabled]) } /** diff --git a/packages/uniswap/src/features/dataApi/searchTokens.test.ts b/packages/uniswap/src/features/dataApi/searchTokens.test.ts index e992b5b3922..7ccb426097b 100644 --- a/packages/uniswap/src/features/dataApi/searchTokens.test.ts +++ b/packages/uniswap/src/features/dataApi/searchTokens.test.ts @@ -1,4 +1,5 @@ import { waitFor } from '@testing-library/react-native' +import { TokenQuery } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' import { useSearchTokens } from 'uniswap/src/features/dataApi/searchTokens' import { useTokenProjects } from 'uniswap/src/features/dataApi/tokenProjects' import { gqlTokenToCurrencyInfo } from 'uniswap/src/features/dataApi/utils' @@ -25,15 +26,26 @@ describe(useTokenProjects, () => { const { resolvers, resolved } = queryResolvers({ searchTokens: () => createArray(5, token), }) - const { result } = renderHook(() => useSearchTokens('', null, false), { + const { result } = renderHook(() => useSearchTokens('hi', null, false), { resolvers, }) await waitFor(async () => { - const expectedData = (await resolved.searchTokens).map(gqlTokenToCurrencyInfo).map(removeSafetyInfo) + const expectedData = (await resolved.searchTokens) + .map(removeIsSpam) + .map(gqlTokenToCurrencyInfo) + .map(removeSafetyInfo) const actualData = result.current.data?.map(removeSafetyInfo) expect(actualData).toEqual(expectedData) }) }) }) + +// TODO(WALL-5157): remove once `queryResolvers` is fixed. +function removeIsSpam( + searchToken: NonNullable>, +): NonNullable> { + delete searchToken.project?.isSpam + return searchToken +} diff --git a/packages/uniswap/src/features/dataApi/searchTokens.ts b/packages/uniswap/src/features/dataApi/searchTokens.ts index 1a2bd32f20c..7bfd459ae2e 100644 --- a/packages/uniswap/src/features/dataApi/searchTokens.ts +++ b/packages/uniswap/src/features/dataApi/searchTokens.ts @@ -19,7 +19,7 @@ export function useSearchTokens( searchQuery: searchQuery ?? '', chains: gqlChainFilter ? [gqlChainFilter] : gqlChains, }, - skip, + skip: skip || !searchQuery, }) const persistedError = usePersistedError(loading, error) diff --git a/packages/uniswap/src/features/dataApi/types.ts b/packages/uniswap/src/features/dataApi/types.ts index 10732f490b3..b2ff404dbc1 100644 --- a/packages/uniswap/src/features/dataApi/types.ts +++ b/packages/uniswap/src/features/dataApi/types.ts @@ -1,5 +1,6 @@ import { Currency } from '@uniswap/sdk-core' import { ProtectionResult, SafetyLevel } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' +import { SpamCode } from 'uniswap/src/data/types' import { CurrencyId } from 'uniswap/src/types/currency' export enum TokenList { @@ -26,12 +27,14 @@ export type CurrencyInfo = { currencyId: CurrencyId safetyLevel: Maybe safetyInfo?: Maybe + spamCode?: Maybe logoUrl: Maybe isSpam?: Maybe } // Portfolio balance as exposed to the app export type PortfolioBalance = { + id: string cacheId: string quantity: number // float representation of balance balanceUSD: Maybe diff --git a/packages/uniswap/src/features/dataApi/utils.test.ts b/packages/uniswap/src/features/dataApi/utils.test.ts index 8c7ea173b34..96ffdde8d09 100644 --- a/packages/uniswap/src/features/dataApi/utils.test.ts +++ b/packages/uniswap/src/features/dataApi/utils.test.ts @@ -107,6 +107,22 @@ describe(buildCurrency, () => { expect(token.name).toBe('Test Token') }) + it('should return the same reference when the same parameters are provided', () => { + const args = { + chainId: UniverseChainId.Mainnet, + address: '0x0000000000000000000000000000000000000000', + decimals: 0, + symbol: 'TEST', + name: 'Test Token', + } + + const tokenA = buildCurrency({ ...args }) as Token + const tokenB = buildCurrency({ ...args }) as Token + + expect(tokenA).toBeInstanceOf(Token) + expect(tokenA).toBe(tokenB) + }) + it('should return a new NativeCurrency instance when address is not provided', () => { const nativeCurrency = buildCurrency({ chainId: UniverseChainId.Mainnet, diff --git a/packages/uniswap/src/features/dataApi/utils.ts b/packages/uniswap/src/features/dataApi/utils.ts index 4e4b6661588..30eafb9fd51 100644 --- a/packages/uniswap/src/features/dataApi/utils.ts +++ b/packages/uniswap/src/features/dataApi/utils.ts @@ -22,6 +22,7 @@ import { currencyIdToGraphQLAddress, isNativeCurrencyAddress, } from 'uniswap/src/utils/currencyId' +import { sortKeysRecursively } from 'utilities/src/primitives/objects' type BuildCurrencyParams = { chainId?: Nullable @@ -51,7 +52,7 @@ export function tokenProjectToCurrencyInfos( ?.flatMap((project) => project?.tokens.map((token) => { const { logoUrl, safetyLevel } = project ?? {} - const { name, chain, address, decimals, symbol } = token ?? {} + const { name, chain, address, decimals, symbol, feeData, protectionInfo } = token ?? {} const chainId = fromGraphQLChain(chain) if (chainFilter && chainFilter !== chainId) { @@ -64,22 +65,21 @@ export function tokenProjectToCurrencyInfos( decimals, symbol, name, + buyFeeBps: feeData?.buyFeeBps, + sellFeeBps: feeData?.sellFeeBps, }) if (!currency) { return null } - const currencyInfo: CurrencyInfo = { + const currencyInfo = buildCurrencyInfo({ currency, currencyId: currencyId(currency), logoUrl, safetyLevel, - safetyInfo: { - tokenList: getTokenListFromSafetyLevel(project?.safetyLevel), - protectionResult: ProtectionResult.Unknown, - }, - } + safetyInfo: getCurrencySafetyInfo(safetyLevel, protectionInfo), + }) return currencyInfo }), @@ -92,8 +92,10 @@ function isNonNativeAddress(chainId: UniverseChainId, address: Maybe): a return !isNativeCurrencyAddress(chainId, address) } +const CURRENCY_CACHE = new Map() + /** - * Creates a new instance of Token or NativeCurrency. + * Creates a new instance of Token or NativeCurrency, or returns an existing copy if one was already created. * * @param params The parameters for building the currency. * @param params.chainId The ID of the chain where the token resides. If not provided, the function will return undefined. @@ -104,26 +106,47 @@ function isNonNativeAddress(chainId: UniverseChainId, address: Maybe): a * @param params.bypassChecksum If true, bypasses the EIP-55 checksum on the token address. This parameter is optional and defaults to true. * @returns A new instance of Token or NativeCurrency if the parameters are valid, otherwise returns undefined. */ -export function buildCurrency({ - chainId, - address, - decimals, - symbol, - name, - bypassChecksum = true, - buyFeeBps, - sellFeeBps, -}: BuildCurrencyParams): Token | NativeCurrency | undefined { +export function buildCurrency(args: BuildCurrencyParams): Token | NativeCurrency | undefined { + const { chainId, address, decimals, symbol, name, bypassChecksum = true, buyFeeBps, sellFeeBps } = args + if (!chainId || decimals === undefined || decimals === null) { return undefined } + const cacheKey = JSON.stringify(sortKeysRecursively(args)) + + const cachedCurrency = CURRENCY_CACHE.get(cacheKey) + + if (cachedCurrency) { + // This allows us to better memoize components that use a `Currency` as a dependency. + return cachedCurrency + } + const buyFee = buyFeeBps && BigNumber.from(buyFeeBps).gt(0) ? BigNumber.from(buyFeeBps) : undefined const sellFee = sellFeeBps && BigNumber.from(sellFeeBps).gt(0) ? BigNumber.from(sellFeeBps) : undefined - return isNonNativeAddress(chainId, address) + const result = isNonNativeAddress(chainId, address) ? new Token(chainId, address, decimals, symbol ?? undefined, name ?? undefined, bypassChecksum, buyFee, sellFee) : NativeCurrency.onChain(chainId) + + CURRENCY_CACHE.set(cacheKey, result) + return result +} + +const CURRENCY_INFO_CACHE = new Map() + +export function buildCurrencyInfo(args: CurrencyInfo): CurrencyInfo { + const cacheKey = JSON.stringify(sortKeysRecursively(args)) + + const cachedCurrencyInfo = CURRENCY_INFO_CACHE.get(cacheKey) + + if (cachedCurrencyInfo) { + // This allows us to better memoize components that use a `CurrencyInfo` as a dependency. + return cachedCurrencyInfo + } + + CURRENCY_INFO_CACHE.set(cacheKey, args) + return args } function getTokenListFromSafetyLevel(safetyInfo?: SafetyLevel): TokenList { @@ -147,6 +170,8 @@ function getHighestPriorityAttackType(attackTypes?: (ProtectionAttackType | unde return AttackType.Impersonator } else if (attackTypeSet.has(ProtectionAttackType.AirdropPattern)) { return AttackType.Airdrop + } else if (attackTypeSet.has(ProtectionAttackType.HighFees)) { + return AttackType.HighFees } else { return AttackType.Other } @@ -181,7 +206,7 @@ export function gqlTokenToCurrencyInfo(token: NonNullable, ) => { - const isVisible = state.nftsVisibility[nftKey]?.isVisible ?? isSpam === false + const isVisible = state.nftsVisibility[nftKey]?.isVisible ?? !isSpam + state.nftsVisibility[nftKey] = { isVisible: !isVisible } }, }, diff --git a/packages/uniswap/src/features/fiatOnRamp/useCexTransferProviders.ts b/packages/uniswap/src/features/fiatOnRamp/useCexTransferProviders.ts index d3cfa60be42..ee1ddd91aa1 100644 --- a/packages/uniswap/src/features/fiatOnRamp/useCexTransferProviders.ts +++ b/packages/uniswap/src/features/fiatOnRamp/useCexTransferProviders.ts @@ -1,14 +1,17 @@ +import { useMemo } from 'react' import { useFiatOnRampAggregatorTransferServiceProvidersQuery } from 'uniswap/src/features/fiatOnRamp/api' import { FORServiceProvider } from 'uniswap/src/features/fiatOnRamp/types' export function useCexTransferProviders(params?: { isDisabled?: boolean }): FORServiceProvider[] { - const { data, isLoading } = useFiatOnRampAggregatorTransferServiceProvidersQuery(undefined, { + const { data } = useFiatOnRampAggregatorTransferServiceProvidersQuery(undefined, { skip: params?.isDisabled, }) - if (isLoading || !data) { - return [] - } + return useMemo(() => { + if (!data) { + return [] + } - return data.serviceProviders + return data.serviceProviders + }, [data]) } diff --git a/packages/uniswap/src/features/gating/flags.ts b/packages/uniswap/src/features/gating/flags.ts index 6007d3c23d7..a98ff88410f 100644 --- a/packages/uniswap/src/features/gating/flags.ts +++ b/packages/uniswap/src/features/gating/flags.ts @@ -47,18 +47,15 @@ export enum FeatureFlags { GqlTokenLists, LimitsFees, L2NFTs, - MultichainExplore, MultipleRoutingOptions, QuickRouteMainnet, Realtime, - RestExplore, TraceJsonRpc, UniswapXSyntheticQuote, UniswapXv2, V2Everywhere, V4Everywhere, Zora, - WorldChain, // TODO(WEB-3625): Remove these once we have a generalized system for outage banners. OutageBannerArbitrum, OutageBannerOptimism, @@ -79,7 +76,6 @@ export const WEB_FEATURE_FLAG_NAMES = new Map([ [FeatureFlags.SharedSwapArbitrumUniswapXExperiment, 'shared_swap_arbitrum_uniswapx_experiment'], [FeatureFlags.TestnetMode, 'testnet-mode'], [FeatureFlags.V4Swap, 'v4_swap'], - [FeatureFlags.WorldChain, 'world_chain'], // Web Specific [FeatureFlags.UniversalSwap, 'universal_swap'], @@ -88,11 +84,9 @@ export const WEB_FEATURE_FLAG_NAMES = new Map([ [FeatureFlags.GqlTokenLists, 'gql_token_lists'], [FeatureFlags.LimitsFees, 'limits_fees'], [FeatureFlags.L2NFTs, 'l2_nfts'], - [FeatureFlags.MultichainExplore, 'multichain_explore'], [FeatureFlags.MultipleRoutingOptions, 'multiple_routing_options'], [FeatureFlags.QuickRouteMainnet, 'enable_quick_route_mainnet'], [FeatureFlags.Realtime, 'realtime'], - [FeatureFlags.RestExplore, 'rest_explore'], [FeatureFlags.TraceJsonRpc, 'traceJsonRpc'], [FeatureFlags.AstroChainLaunchModal, 'astro_chain_launch_modal'], [FeatureFlags.UniswapXSyntheticQuote, 'uniswapx_synthetic_quote'], @@ -120,7 +114,6 @@ export const WALLET_FEATURE_FLAG_NAMES = new Map([ [FeatureFlags.SharedSwapArbitrumUniswapXExperiment, 'shared_swap_arbitrum_uniswapx_experiment'], [FeatureFlags.TestnetMode, 'testnet-mode'], [FeatureFlags.V4Swap, 'v4_swap'], - [FeatureFlags.WorldChain, 'world_chain'], // Wallet Specific [FeatureFlags.Datadog, 'datadog'], diff --git a/packages/uniswap/src/features/notifications/types.ts b/packages/uniswap/src/features/notifications/types.ts index 1c48bd61f25..e7854661590 100644 --- a/packages/uniswap/src/features/notifications/types.ts +++ b/packages/uniswap/src/features/notifications/types.ts @@ -150,6 +150,7 @@ export enum CopyNotificationType { TransactionId = 'transactionId', Image = 'image', TokenUrl = 'tokenUrl', + BlockExplorerUrl = 'blockExplorerUrl', NftUrl = 'nftUrl', } diff --git a/packages/uniswap/src/features/search/SearchResult.ts b/packages/uniswap/src/features/search/SearchResult.ts index 031cdcab35c..e225f425718 100644 --- a/packages/uniswap/src/features/search/SearchResult.ts +++ b/packages/uniswap/src/features/search/SearchResult.ts @@ -1,4 +1,4 @@ -import { SafetyLevel } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' +import { FeeData, SafetyLevel } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' import { SafetyInfo } from 'uniswap/src/features/dataApi/types' import { UniverseChainId } from 'uniswap/src/types/chains' @@ -30,6 +30,7 @@ export interface TokenSearchResult extends SearchResultBase { logoUrl: string | null safetyLevel: SafetyLevel | null safetyInfo?: SafetyInfo | null + feeData?: FeeData | null } export function isTokenSearchResult(x: SearchResult): x is TokenSearchResult { diff --git a/packages/uniswap/src/features/settings/hooks.test.ts b/packages/uniswap/src/features/settings/hooks.test.ts new file mode 100644 index 00000000000..fc0033eabd7 --- /dev/null +++ b/packages/uniswap/src/features/settings/hooks.test.ts @@ -0,0 +1,74 @@ +import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' +import { + TESTNET_MODE_BANNER_HEIGHT, + useHideSpamTokensSetting, + useTestnetModeBannerHeight, +} from 'uniswap/src/features/settings/hooks' +import { selectIsTestnetModeEnabled, selectWalletHideSpamTokensSetting } from 'uniswap/src/features/settings/selectors' + +import { renderHook } from 'uniswap/src/test/test-utils' + +jest.mock('utilities/src/platform', () => ({ + isMobileApp: jest.fn(), +})) + +jest.mock('uniswap/src/features/gating/hooks', () => ({ + useFeatureFlag: jest.fn(), +})) + +jest.mock('uniswap/src/features/settings/selectors', () => ({ + selectIsTestnetModeEnabled: jest.fn(), + selectWalletHideSmallBalancesSetting: jest.fn(), + selectWalletHideSpamTokensSetting: jest.fn(), +})) + +const mockedSelectIsTestnetModeEnabled = selectIsTestnetModeEnabled as jest.Mock +const mockedSelectWalletHideSpamTokensSetting = selectWalletHideSpamTokensSetting as jest.Mock +const mockedUseFeatureFlag = useFeatureFlag as jest.Mock + +describe('useHideSpamTokensSetting', () => { + it('should return true when hideSpamTokens is true', () => { + mockedSelectWalletHideSpamTokensSetting.mockReturnValue(true) + + const { result } = renderHook(() => useHideSpamTokensSetting()) + + expect(result.current).toBe(true) + }) + + it('should return false when hideSpamTokens is false', () => { + mockedSelectWalletHideSpamTokensSetting.mockReturnValue(false) + + const { result } = renderHook(() => useHideSpamTokensSetting()) + + expect(result.current).toBe(false) + }) + + describe('useTestnetModeBannerHeight', () => { + it('should return TESTNET_MODE_BANNER_HEIGHT when isTestnetModeEnabled is true and isMobileApp is true', () => { + mockedSelectIsTestnetModeEnabled.mockReturnValue(true) + mockedUseFeatureFlag.mockReturnValue(true) + + const { result } = renderHook(() => useTestnetModeBannerHeight()) + + expect(result.current).toBe(TESTNET_MODE_BANNER_HEIGHT) + }) + + it('should return 0 when isTestnetModeEnabled is true and isMobileApp is false', () => { + mockedSelectIsTestnetModeEnabled.mockReturnValue(true) + mockedUseFeatureFlag.mockReturnValue(false) + + const { result } = renderHook(() => useTestnetModeBannerHeight()) + + expect(result.current).toBe(0) + }) + + it('should return 0 when isTestnetModeEnabled is false', () => { + mockedSelectIsTestnetModeEnabled.mockReturnValue(false) + mockedUseFeatureFlag.mockReturnValue(false) + + const { result } = renderHook(() => useTestnetModeBannerHeight()) + + expect(result.current).toBe(0) + }) + }) +}) diff --git a/packages/uniswap/src/features/settings/hooks.ts b/packages/uniswap/src/features/settings/hooks.ts index 01849c304b6..130bea9f519 100644 --- a/packages/uniswap/src/features/settings/hooks.ts +++ b/packages/uniswap/src/features/settings/hooks.ts @@ -14,9 +14,11 @@ import { WalletConnectConnector } from 'uniswap/src/features/web3/walletConnect' import { COMBINED_CHAIN_IDS, InterfaceGqlChain, UniverseChainId } from 'uniswap/src/types/chains' import { isTestEnv } from 'utilities/src/environment/env' import { logger } from 'utilities/src/logger/logger' -import { isInterface } from 'utilities/src/platform' +import { isInterface, isMobileApp } from 'utilities/src/platform' import { Connector } from 'wagmi' +export const TESTNET_MODE_BANNER_HEIGHT = 44 + export function useHideSmallBalancesSetting(): boolean { const { isTestnetModeEnabled } = useEnabledChains() @@ -24,9 +26,7 @@ export function useHideSmallBalancesSetting(): boolean { } export function useHideSpamTokensSetting(): boolean { - const { isTestnetModeEnabled } = useEnabledChains() - - return useSelector(selectWalletHideSpamTokensSetting) && !isTestnetModeEnabled + return useSelector(selectWalletHideSpamTokensSetting) } // Note: only use this hook for useConnectedWalletSupportedChains @@ -63,6 +63,12 @@ function useConnectedWalletSupportedChains(): UniverseChainId[] { }, [connector]) } +function useIsTestnetModeEnabled(): boolean { + const isTestnetModeFromState = useSelector(selectIsTestnetModeEnabled) + const isTestnetModeFromFlag = useFeatureFlag(FeatureFlags.TestnetMode) + return isTestnetModeFromState && isTestnetModeFromFlag +} + export function useEnabledChains(): { chains: UniverseChainId[] gqlChains: InterfaceGqlChain[] @@ -71,12 +77,21 @@ export function useEnabledChains(): { } { const featureFlaggedChainIds = useFeatureFlaggedChainIds() const connectedWalletChainIds = useConnectedWalletSupportedChains() - const isTestnetModeFromState = useSelector(selectIsTestnetModeEnabled) - const isTestnetModeFromFlag = useFeatureFlag(FeatureFlags.TestnetMode) - const isTestnetModeEnabled = isTestnetModeFromState && isTestnetModeFromFlag + const isTestnetModeEnabled = useIsTestnetModeEnabled() return useMemo( () => getEnabledChains({ isTestnetModeEnabled, connectedWalletChainIds, featureFlaggedChainIds }), [isTestnetModeEnabled, connectedWalletChainIds, featureFlaggedChainIds], ) } + +/** + * Use to account for an inset when `useAppInsets()` is not available + * + * @returns The height of the testnet mode banner if testnet mode is enabled, otherwise 0 + */ +export function useTestnetModeBannerHeight(): number { + const isTestnetModeEnabled = useIsTestnetModeEnabled() + + return isTestnetModeEnabled && isMobileApp ? TESTNET_MODE_BANNER_HEIGHT : 0 +} diff --git a/packages/uniswap/src/features/settings/saga.ts b/packages/uniswap/src/features/settings/saga.ts index 687900ee572..993c8124743 100644 --- a/packages/uniswap/src/features/settings/saga.ts +++ b/packages/uniswap/src/features/settings/saga.ts @@ -3,17 +3,12 @@ import { filterChainIdsByFeatureFlag, getEnabledChains } from 'uniswap/src/featu import { FeatureFlags } from 'uniswap/src/features/gating/flags' import { getFeatureFlag } from 'uniswap/src/features/gating/hooks' import { selectIsTestnetModeEnabled } from 'uniswap/src/features/settings/selectors' -import { UniverseChainId } from 'uniswap/src/types/chains' export function* getEnabledChainIdsSaga() { - const testnetModeFeatureFlag = getFeatureFlag(FeatureFlags.Datadog) + const testnetModeFeatureFlag = getFeatureFlag(FeatureFlags.TestnetMode) const testnetModeEnabled = yield* select(selectIsTestnetModeEnabled) - const worldChainEnabled = getFeatureFlag(FeatureFlags.WorldChain) - - const featureFlaggedChainIds = filterChainIdsByFeatureFlag({ - [UniverseChainId.WorldChain]: worldChainEnabled, - }) + const featureFlaggedChainIds = filterChainIdsByFeatureFlag({}) return yield* call(getEnabledChains, { isTestnetModeEnabled: testnetModeEnabled && testnetModeFeatureFlag, diff --git a/packages/uniswap/src/features/settings/slice.ts b/packages/uniswap/src/features/settings/slice.ts index 86965319f13..9c78ffb3e76 100644 --- a/packages/uniswap/src/features/settings/slice.ts +++ b/packages/uniswap/src/features/settings/slice.ts @@ -1,6 +1,7 @@ import { createAction, createSlice, PayloadAction } from '@reduxjs/toolkit' import { FiatCurrency } from 'uniswap/src/features/fiatCurrency/constants' import { Language } from 'uniswap/src/features/language/constants' +import { WALLET_TESTNET_CONFIG } from 'uniswap/src/features/telemetry/constants' // eslint-disable-next-line no-restricted-imports import { analytics } from 'utilities/src/telemetry/analytics/analytics' @@ -41,7 +42,7 @@ const slice = createSlice({ */ setIsTestnetModeEnabled: (state, { payload }: PayloadAction) => { state.isTestnetModeEnabled = payload - analytics.setTestnetMode(payload) + analytics.setTestnetMode(payload, WALLET_TESTNET_CONFIG) }, resetSettings: () => initialUserSettingsState, }, diff --git a/packages/uniswap/src/features/telemetry/constants/trace.ts b/packages/uniswap/src/features/telemetry/constants/trace.ts index 7e16cb48b64..4a40b393c8e 100644 --- a/packages/uniswap/src/features/telemetry/constants/trace.ts +++ b/packages/uniswap/src/features/telemetry/constants/trace.ts @@ -4,6 +4,7 @@ import { InterfacePageName, InterfaceSectionName, } from '@uniswap/analytics-events' +import { OnboardingCardLoggingName } from 'uniswap/src/features/telemetry/types' export const ModalName = { AccountEdit: 'account-edit-modal', @@ -41,8 +42,10 @@ export const ModalName = { KoreaCexTransferInfoModal: 'korea-cex-transfer-info-modal', HiddenTokenInfoModal: 'hidden-token-info-modal', HiddenNFTInfoModal: 'hidden-nft-info-modal', + Hook: 'hook', Legal: 'legal', LanguageSelector: 'language-selector-modal', + MigrateLiquidity: 'migrate-liquidity', NewAddressWarning: 'new-address-warning-modal', NetworkFeeInfo: 'network-fee-info', NetworkSelector: 'network-selector-modal', @@ -72,6 +75,7 @@ export const ModalName = { SlippageInfo: 'slippage-info-modal', StorageWarning: 'storage-warning-modal', Swap: 'swap-modal', + SwapError: 'swap-error-modal', SwapReview: 'swap-review-modal', SwapSettings: 'swap-settings-modal', SwapWarning: 'swap-warning-modal', @@ -112,6 +116,7 @@ export const ElementName = { AddViewOnlyWallet: 'add-view-only-wallet', AddCloudBackup: 'add-cloud-backup', AlreadyHaveWalletSignIn: 'already-have-wallet-sign-in', + BackButton: 'back-button', Buy: 'buy', BuyNativeTokenButton: 'buy-native-token-button', BridgeNativeTokenButton: 'bridge-native-token-button', @@ -148,6 +153,7 @@ export const ElementName = { GetHelp: 'get-help', ImportAccount: 'import-account', LimitOrderButton: 'limit-order-button', + MaybeLaterButton: 'maybe-later-button', MoonpayExplorerView: 'moonpay-explorer-view', NetworkButton: 'network-button', Next: 'next', @@ -203,7 +209,10 @@ export const ElementName = { // alphabetize additional values. } as const -export type ElementNameType = (typeof ElementName)[keyof typeof ElementName] | InterfaceElementName +export type ElementNameType = + | (typeof ElementName)[keyof typeof ElementName] + | InterfaceElementName + | OnboardingCardLoggingName /** * Possible names for the section property in TraceContext diff --git a/packages/uniswap/src/features/telemetry/constants/wallet.ts b/packages/uniswap/src/features/telemetry/constants/wallet.ts index c7b43df1fbd..f8bd5e27bb2 100644 --- a/packages/uniswap/src/features/telemetry/constants/wallet.ts +++ b/packages/uniswap/src/features/telemetry/constants/wallet.ts @@ -1,6 +1,16 @@ +import { SharedEventName, SwapEventName } from '@uniswap/analytics-events' +import { ExtensionEventName } from 'uniswap/src/features/telemetry/constants/extension' +// eslint-disable-next-line no-restricted-imports +import { TestnetModeConfig } from 'utilities/src/telemetry/analytics/analytics' + export enum WalletEventName { + BackupMethodAdded = 'Backup Method Added', + BackupMethodRemoved = 'Backup Method Removed', + DappRequestCardPressed = 'DappRequestCardPressed', + DappRequestCardClosed = 'DappRequestCardClosed', GasEstimateAccuracy = 'Gas Estimate Accuracy', ExploreSearchCancel = 'Explore Search Cancel', + ModalClosed = 'Modal Closed', NFTVisibilityChanged = 'NFT Visibility Changed', NFTsLoaded = 'NFTs Loaded', NetworkFilterSelected = 'Network Filter Selected', @@ -13,6 +23,7 @@ export enum WalletEventName { SendRecipientSelected = 'Send Recipient Selected', ShareButtonClicked = 'Share Button Clicked', SwapSubmitted = 'Swap Submitted to Provider', + TestnetEvent = 'Testnet Event', TokenVisibilityChanged = 'Token Visibility Changed', TestnetModeToggled = 'Testnet Mode Toggled', TransferCompleted = 'Transfer Completed', @@ -21,3 +32,25 @@ export enum WalletEventName { WalletAdded = 'Wallet Added', WalletRemoved = 'Wallet Removed', } + +export const WALLET_TESTNET_CONFIG: TestnetModeConfig = { + allowlistEvents: [ + WalletEventName.NetworkFilterSelected, + WalletEventName.TransferCompleted, + WalletEventName.TransferSubmitted, + SharedEventName.PAGE_VIEWED, + SwapEventName.SWAP_TRANSACTION_COMPLETED, + SwapEventName.SWAP_TRANSACTION_FAILED, + ExtensionEventName.DappRequest, + WalletEventName.SwapSubmitted, + WalletEventName.TransferSubmitted, + WalletEventName.TransferCompleted, + ], + passthroughAllowlistEvents: [ + ExtensionEventName.DappConnect, + ExtensionEventName.DappDisconnect, + ExtensionEventName.DappDisconnectAll, + ExtensionEventName.DappTroubleConnecting, + ], + aggregateEventName: WalletEventName.TestnetEvent, +} diff --git a/packages/uniswap/src/features/telemetry/types.ts b/packages/uniswap/src/features/telemetry/types.ts index 9ef09e4cba3..c3d09e498a2 100644 --- a/packages/uniswap/src/features/telemetry/types.ts +++ b/packages/uniswap/src/features/telemetry/types.ts @@ -23,6 +23,7 @@ import { WalletConnectionResult, } from '@uniswap/analytics-events' import { Protocol } from '@uniswap/router-sdk' +import { TokenOptionSection } from 'uniswap/src/components/TokenSelector/types' import { NftStandard } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' import { FiatCurrency } from 'uniswap/src/features/fiatCurrency/constants' import { @@ -254,6 +255,8 @@ export enum DappRequestAction { Reject = 'Reject', } +export type CardLoggingName = OnboardingCardLoggingName | DappRequestCardLoggingName + export enum OnboardingCardLoggingName { WelcomeWallet = 'welcome_wallet', FundWallet = 'fund_wallet', @@ -262,6 +265,10 @@ export enum OnboardingCardLoggingName { BridgingBanner = 'bridging_banner', } +export enum DappRequestCardLoggingName { + BridgingBanner = 'dapp_request_bridging_banner', +} + export type FORAmountEnteredProperties = ITraceContext & { source: 'chip' | 'textInput' amountUSD?: number @@ -285,6 +292,10 @@ export type FORWidgetOpenedProperties = ITraceContext & { serviceProvider: string } +type DappRequestCardEventProperties = ITraceContext & { + card_name: DappRequestCardLoggingName +} + type OnboardingCardEventProperties = ITraceContext & { card_name: OnboardingCardLoggingName } @@ -722,6 +733,7 @@ export type UniverseEventProperties = { AssetDetailsBaseProperties & SearchResultContextProperties & { field: CurrencyField + tokenSection: TokenOptionSection }) | InterfaceTokenSelectedProperties [UnitagEventName.UnitagBannerActionTaken]: { @@ -742,6 +754,16 @@ export type UniverseEventProperties = { twitter: boolean } [UnitagEventName.UnitagRemoved]: undefined + [WalletEventName.BackupMethodAdded]: { + backupMethodType: 'manual' | 'cloud' + newBackupCount: number + } + [WalletEventName.BackupMethodRemoved]: { + backupMethodType: 'manual' | 'cloud' + newBackupCount: number + } + [WalletEventName.DappRequestCardPressed]: DappRequestCardEventProperties + [WalletEventName.DappRequestCardClosed]: DappRequestCardEventProperties [WalletEventName.ExternalLinkOpened]: { url: string } @@ -754,6 +776,7 @@ export type UniverseEventProperties = { [WalletEventName.ExploreSearchCancel]: { query: string } + [WalletEventName.ModalClosed]: ITraceContext & Record [WalletEventName.NetworkFilterSelected]: ITraceContext & { chain: UniverseChainId | 'All' } @@ -800,6 +823,9 @@ export type UniverseEventProperties = { [WalletEventName.TestnetModeToggled]: { enabled: boolean } + [WalletEventName.TestnetEvent]: { + originalEventName: string + } & Record [WalletEventName.ViewRecoveryPhrase]: undefined // Please sort new values by EventName type! } diff --git a/packages/uniswap/src/features/telemetry/user.ts b/packages/uniswap/src/features/telemetry/user.ts index 6381b3cc05e..374cba36ff6 100644 --- a/packages/uniswap/src/features/telemetry/user.ts +++ b/packages/uniswap/src/features/telemetry/user.ts @@ -22,6 +22,7 @@ export enum MobileUserPropertyName { IsPushEnabled = 'is_push_enabled', Language = 'language', MnemonicCount = 'mnemonic_count', + TestnetModeEnabled = 'testnet_mode_enabled', TransactionAuthMethod = 'transaction_auth_method', WalletSignerAccounts = `wallet_signer_accounts`, WalletSignerCount = 'wallet_signer_count', @@ -42,6 +43,7 @@ export enum ExtensionUserPropertyName { IsHideSmallBalancesEnabled = 'is_hide_small_balances_enabled', IsHideSpamTokensEnabled = 'is_hide_spam_tokens_enabled', Language = 'language', + TestnetModeEnabled = 'testnet_mode_enabled', WalletSignerAccounts = `wallet_signer_accounts`, WalletSignerCount = 'wallet_signer_count', WalletViewOnlyCount = 'wallet_view_only_count', diff --git a/packages/uniswap/src/features/tokens/TokenWarningCard.tsx b/packages/uniswap/src/features/tokens/TokenWarningCard.tsx index 40b10af224f..2c11ef85554 100644 --- a/packages/uniswap/src/features/tokens/TokenWarningCard.tsx +++ b/packages/uniswap/src/features/tokens/TokenWarningCard.tsx @@ -1,26 +1,99 @@ +import { TouchableArea } from 'ui/src' import { InlineWarningCard } from 'uniswap/src/components/InlineWarningCard/InlineWarningCard' +import { WarningSeverity } from 'uniswap/src/components/modals/WarningModal/types' import { CurrencyInfo } from 'uniswap/src/features/dataApi/types' -import { getTokenWarningSeverity, useTokenWarningCardText } from 'uniswap/src/features/tokens/safetyUtils' +import { useLocalizationContext } from 'uniswap/src/features/language/LocalizationContext' +import { + TokenProtectionWarning, + getCardHeaderText, + getCardSubtitleText, + getFeeOnTransfer, + getSeverityFromTokenProtectionWarning, + getTokenWarningSeverity, + useTokenWarningCardText, +} from 'uniswap/src/features/tokens/safetyUtils' +import { useTranslation } from 'uniswap/src/i18n' type TokenWarningCardProps = { currencyInfo: Maybe - onPressCtaButton?: () => void + tokenProtectionWarningOverride?: TokenProtectionWarning + feePercentOverride?: number + onPress?: () => void + headingTestId?: string + descriptionTestId?: string + hideCtaIcon?: boolean + checked?: boolean + setChecked?: (checked: boolean) => void } -export function TokenWarningCard({ currencyInfo, onPressCtaButton }: TokenWarningCardProps): JSX.Element | null { - const severity = getTokenWarningSeverity(currencyInfo) - const { heading, description } = useTokenWarningCardText(currencyInfo) +function useTokenWarningOverrides( + currencyInfo: Maybe, + tokenProtectionWarningOverride?: TokenProtectionWarning, + feePercentOverride?: number, +): { severity: WarningSeverity; heading: string | null; description: string | null } { + const { t } = useTranslation() + const { formatPercent } = useLocalizationContext() + const { heading: headingDefault, description: descriptionDefault } = useTokenWarningCardText(currencyInfo) + + const severity = tokenProtectionWarningOverride + ? getSeverityFromTokenProtectionWarning(tokenProtectionWarningOverride) + : getTokenWarningSeverity(currencyInfo) + + const headingOverride = getCardHeaderText({ + t, + tokenProtectionWarning: tokenProtectionWarningOverride ?? TokenProtectionWarning.None, + }) + + const descriptionOverride = getCardSubtitleText({ + t, + tokenProtectionWarning: tokenProtectionWarningOverride ?? TokenProtectionWarning.None, + tokenSymbol: currencyInfo?.currency.symbol, + feePercent: feePercentOverride ?? getFeeOnTransfer(currencyInfo?.currency), + formatPercent, + }) + + const heading = tokenProtectionWarningOverride ? headingOverride : headingDefault + const description = tokenProtectionWarningOverride ? descriptionOverride : descriptionDefault + + return { severity, heading, description } +} + +export function TokenWarningCard({ + currencyInfo, + tokenProtectionWarningOverride, + feePercentOverride, + headingTestId, + descriptionTestId, + hideCtaIcon, + checked, + setChecked, + onPress, +}: TokenWarningCardProps): JSX.Element | null { + const { t } = useTranslation() + const { severity, heading, description } = useTokenWarningOverrides( + currencyInfo, + tokenProtectionWarningOverride, + feePercentOverride, + ) if (!currencyInfo || !severity || !description) { return null } return ( - + + + ) } diff --git a/packages/uniswap/src/features/tokens/TokenWarningModal.tsx b/packages/uniswap/src/features/tokens/TokenWarningModal.tsx index 74f0a243a1b..e47c1c1d608 100644 --- a/packages/uniswap/src/features/tokens/TokenWarningModal.tsx +++ b/packages/uniswap/src/features/tokens/TokenWarningModal.tsx @@ -1,4 +1,6 @@ import { BigNumber } from '@ethersproject/bignumber' +import { Percent } from '@uniswap/sdk-core' +import { TFunction } from 'i18next' import { useState } from 'react' import { Trans } from 'react-i18next' import { capitalize } from 'tsafe' @@ -20,15 +22,19 @@ import { useLocalizationContext } from 'uniswap/src/features/language/Localizati import { ModalName } from 'uniswap/src/features/telemetry/constants' import DeprecatedTokenWarningModal from 'uniswap/src/features/tokens/DeprecatedTokenWarningModal' import { + getFeeOnTransfer, + getFeeWarning, getIsFeeRelatedWarning, + getModalHeaderText, + getModalSubtitleText, + getSeverityFromTokenProtectionWarning, getShouldHaveCombinedPluralTreatment, + getTokenProtectionWarning, getTokenWarningSeverity, - useModalHeaderText, - useModalSubtitleText, } from 'uniswap/src/features/tokens/safetyUtils' +import { useDismissedTokenWarnings } from 'uniswap/src/features/tokens/slice/hooks' import { useTranslation } from 'uniswap/src/i18n' -import { currencyId } from 'uniswap/src/utils/currencyId' -import { NumberType } from 'utilities/src/format/types' +import { currencyId, currencyIdToAddress } from 'uniswap/src/utils/currencyId' import { isMobileApp } from 'utilities/src/platform' interface TokenWarningProps { @@ -37,11 +43,14 @@ interface TokenWarningProps { isInfoOnlyWarning?: boolean // if this is an informational-only warning. Hides the Reject button shouldBeCombinedPlural?: boolean // some 2-token warnings will be combined into one plural modal (see `getShouldHaveCombinedPluralTreatment`) hasSecondWarning?: boolean // true if this is a 2-token warning with two separate warning screens + feeOnTransferOverride?: { fee: Percent; feeType: 'buy' | 'sell' } // used on SwapReviewScreen to force TokenWarningModal to display FOT content and overrides fee with TradingApi's input/output tax } interface TokenWarningModalContentProps extends TokenWarningProps { onRejectButton: () => void onAcknowledgeButton: () => void + onDismissTokenWarning0: () => void + onDismissTokenWarning1?: () => void } interface TokenWarningModalProps extends TokenWarningProps { isVisible: boolean @@ -60,25 +69,70 @@ function TokenWarningModalContent({ onAcknowledgeButton, shouldBeCombinedPlural, hasSecondWarning, + feeOnTransferOverride, + onDismissTokenWarning0, + onDismissTokenWarning1, }: TokenWarningModalContentProps): JSX.Element | null { const { t } = useTranslation() - const [dontShowAgain, setDontShowAgain] = useState(false) // TODO(WALL-4596): implement dismissedTokenWarnings redux + const { formatPercent } = useLocalizationContext() - const severity = getTokenWarningSeverity(currencyInfo0) - const isFeeRelatedWarning = getIsFeeRelatedWarning(currencyInfo0) + const tokenProtectionWarning = feeOnTransferOverride + ? getFeeWarning(feeOnTransferOverride.fee) + : getTokenProtectionWarning(currencyInfo0) + const severity = getSeverityFromTokenProtectionWarning(tokenProtectionWarning) + const feePercent = feeOnTransferOverride + ? parseFloat(feeOnTransferOverride.fee.toFixed()) + : getFeeOnTransfer(currencyInfo0.currency) + const isFeeRelatedWarning = getIsFeeRelatedWarning(tokenProtectionWarning) + const tokenSymbol = currencyInfo0.currency.symbol + const titleText = getModalHeaderText({ + t, + tokenSymbol0: tokenSymbol, + tokenSymbol1: currencyInfo1?.currency.symbol, + tokenProtectionWarning, + shouldHavePluralTreatment: shouldBeCombinedPlural, + }) + const subtitleText = getModalSubtitleText({ + t, + tokenProtectionWarning, + tokenSymbol, + tokenList: currencyInfo0.safetyInfo?.tokenList, + feePercent, + shouldHavePluralTreatment: shouldBeCombinedPlural, + formatPercent, + }) + const { text: titleTextColor } = getAlertColor(severity) + + // Logic for "don't show again" dismissal of warnings + const [dontShowAgain, setDontShowAgain] = useState(false) + const showCheckbox = !isInfoOnlyWarning && severity === WarningSeverity.Low + const showBlockaidLogo = !isFeeRelatedWarning && severity !== WarningSeverity.Low - const titleText = useModalHeaderText(currencyInfo0, shouldBeCombinedPlural ? currencyInfo1 : undefined) - const subtitleText = useModalSubtitleText(currencyInfo0, shouldBeCombinedPlural ? currencyInfo1 : undefined) + const onAcknowledge = (): void => { + if (showCheckbox) { + if (dontShowAgain) { + onDismissTokenWarning0() + onDismissTokenWarning1?.() + } + } + onAcknowledgeButton() + } if (severity === WarningSeverity.None) { return null } - const { text: titleTextColor } = getAlertColor(severity) + const { rejectText, acknowledgeText } = getWarningModalButtonTexts( + t, + !!isInfoOnlyWarning, + severity, + !!hasSecondWarning, + ) return ( @@ -88,23 +142,8 @@ function TokenWarningModalContent({ } - rejectText={ - // if this is an informational-only warning or a 2-token warning, we should always show the Reject / back button - // or, if a token is blocked, it should not have a Reject button, only an Acknowledge button - isInfoOnlyWarning || hasSecondWarning || severity !== WarningSeverity.Blocked - ? t('common.button.back') - : undefined - } - acknowledgeText={ - // if this is an informational-only warning, we don't show the Acknowledge button at all - isInfoOnlyWarning - ? undefined - : // if a token is blocked & is not part of a 2-token warning, the Acknowledge button should say "Close" - severity === WarningSeverity.Blocked && !hasSecondWarning - ? t('common.button.close') - : // otherwise, Acknowledge button should say "Continue" - t('common.button.continue') - } + rejectText={rejectText} + acknowledgeText={acknowledgeText} icon={} backgroundIconColor={false} severity={severity} @@ -115,7 +154,7 @@ function TokenWarningModalContent({ } onReject={onRejectButton} onClose={onRejectButton} - onAcknowledge={onAcknowledgeButton} + onAcknowledge={onAcknowledge} > {isFeeRelatedWarning && currencyInfo0.currency.isToken ? ( )} - {!isFeeRelatedWarning && ( + {showBlockaidLogo && ( )} - {!isInfoOnlyWarning && severity === WarningSeverity.Low && ( + {showCheckbox && ( // only show "Don't show this warning again" checkbox if this is an actionable modal & the token is low-severity void + currencyInfo1: CurrencyInfo | undefined + onDismissTokenWarning1: () => void | undefined +} | null { + const address0 = currencyIdToAddress(t0.currencyId) + const address1 = t1 && currencyIdToAddress(t1.currencyId) + const { tokenWarningDismissed: tokenWarningDismissed0, onDismissTokenWarning: onDismissTokenWarning0 } = + useDismissedTokenWarnings(t0?.currency.isNative ? undefined : { chainId: t0.currency.chainId, address: address0 }) + const { tokenWarningDismissed: tokenWarningDismissed1, onDismissTokenWarning: onDismissTokenWarning1 } = + useDismissedTokenWarnings( + !t1 || !address1 || t1?.currency.isNative ? undefined : { chainId: t1.currency.chainId, address: address1 }, + ) + let currencyInfo0: CurrencyInfo | undefined = t0 + let currencyInfo1: CurrencyInfo | undefined = t1 + if (!isInfoOnlyWarning) { + if (tokenWarningDismissed0 && tokenWarningDismissed1) { + // If both tokens are dismissed + return null + } else if (tokenWarningDismissed0) { + // If only the first token is dismissed, we use currencyInfo1 as primary token to show warning + if (!t1) { + return null + } + currencyInfo0 = t1 ?? undefined + } else if (tokenWarningDismissed1) { + // If only the second token is dismissed, we use currencyInfo0 as primary token to show warning + currencyInfo0 = t0 + currencyInfo1 = undefined + } + } + return { currencyInfo0, onDismissTokenWarning0, currencyInfo1, onDismissTokenWarning1 } +} + /** * Warning speedbump for selecting certain tokens. */ export default function TokenWarningModal({ isVisible, - currencyInfo0, - currencyInfo1, + currencyInfo0: t0, + currencyInfo1: t1, isInfoOnlyWarning, + feeOnTransferOverride, onReject, onToken0BlockAcknowledged, onToken1BlockAcknowledged, @@ -178,14 +258,21 @@ export default function TokenWarningModal({ }: TokenWarningModalProps): JSX.Element | null { const tokenProtectionEnabled = useFeatureFlag(FeatureFlags.TokenProtection) const colors = useSporeColors() + const [warningIndex, setWarningIndex] = useState<0 | 1>(0) + + // Check for dismissed warnings + const warningModalCurrencies = useWarningModalCurrenciesDismissed(t0, t1, isInfoOnlyWarning) + if (!warningModalCurrencies) { + return null + } + const { currencyInfo0, currencyInfo1, onDismissTokenWarning0, onDismissTokenWarning1 } = warningModalCurrencies // If BOTH tokens are blocked or BOTH are low-severity, they'll be combined into one plural modal const combinedPlural = getShouldHaveCombinedPluralTreatment(currencyInfo0, currencyInfo1) const isBlocked0 = getTokenWarningSeverity(currencyInfo0) === WarningSeverity.Blocked const isBlocked1 = getTokenWarningSeverity(currencyInfo1) === WarningSeverity.Blocked - const [warningIndex, setWarningIndex] = useState<0 | 1>(0) - const hasSecondWarning = Boolean(!combinedPlural && currencyInfo1) + const hasSecondWarning = Boolean(!combinedPlural && getTokenWarningSeverity(currencyInfo1) !== WarningSeverity.None) return tokenProtectionEnabled ? ( { if (hasSecondWarning) { @@ -224,15 +312,22 @@ export default function TokenWarningModal({ } else if (isBlocked0) { // If both tokens are blocked, they'll be combined into one plural modal. See `getShouldHaveCombinedPluralTreatment`. combinedPlural && isBlocked1 && onToken1BlockAcknowledged?.() + onToken0BlockAcknowledged?.() + closeModalOnly() + } else if (isInfoOnlyWarning) { + closeModalOnly() } else { onAcknowledge() } }} + onDismissTokenWarning0={onDismissTokenWarning0} + onDismissTokenWarning1={onDismissTokenWarning1} /> {hasSecondWarning && currencyInfo1 && ( { setWarningIndex(0) }} @@ -274,19 +369,19 @@ export const WarningModalInfoContainer = styled(Flex, { flexWrap: 'nowrap', }) -function FeeRow({ feeType, feeBps }: { feeType: 'buy' | 'sell'; feeBps?: BigNumber }): JSX.Element { +// feePercent is the percentage as an integer. I.e. feePercent = 5 means 5% +export function FeeRow({ feeType, feePercent = 0 }: { feeType: 'buy' | 'sell'; feePercent?: number }): JSX.Element { const { t } = useTranslation() - const textColor = getAlertColor(WarningSeverity.Medium) - const { formatNumberOrString } = useLocalizationContext() - const fee: string = feeBps - ? formatNumberOrString({ value: feeBps.toNumber() / 10_000, type: NumberType.Percentage }) - : '0%' + const tokenProtectionWarning = getFeeWarning(new Percent(feePercent, 100)) + const severity = getSeverityFromTokenProtectionWarning(tokenProtectionWarning) + const { headerText: textColor } = getAlertColor(severity) + const { formatPercent } = useLocalizationContext() return ( {feeType === 'buy' ? capitalize(t('token.fee.buy.label')) : capitalize(t('token.fee.sell.label'))} - {fee} + {formatPercent(feePercent)} ) } @@ -298,10 +393,53 @@ export function FeeDisplayTable({ buyFeeBps?: BigNumber sellFeeBps?: BigNumber }): JSX.Element { + const buyFeePercent = buyFeeBps ? buyFeeBps.toNumber() / 100 : undefined + const sellFeePercent = sellFeeBps ? sellFeeBps.toNumber() / 100 : undefined return ( - - + + ) } + +/* +Logic explanation + +Reject button text +- if this is an informational-only warning or a 2-token warning, we should always show the Reject / back button +- or, if a token is blocked, it should not have a Reject button, only an Acknowledge button + +Acknowledge button text +- if this is an informational-only warning, we don't show the Acknowledge button at all +- if a token is blocked & is not part of a 2-token warning, the Acknowledge button should say "Close" +- otherwise, Acknowledge button should say "Continue" +*/ +export function getWarningModalButtonTexts( + t: TFunction, + isInfoOnlyWarning: boolean, + severity: WarningSeverity, + hasSecondWarning: boolean, +): { + rejectText: string | undefined + acknowledgeText: string | undefined +} { + if (isInfoOnlyWarning) { + return { + rejectText: t('common.button.close'), + acknowledgeText: undefined, + } + } + + if (severity === WarningSeverity.Blocked && !hasSecondWarning) { + return { + rejectText: undefined, + acknowledgeText: t('common.button.close'), + } + } + + return { + rejectText: t('common.button.back'), + acknowledgeText: t('common.button.continue'), + } +} diff --git a/packages/uniswap/src/features/tokens/getCurrencyAmount.test.ts b/packages/uniswap/src/features/tokens/getCurrencyAmount.test.ts index 80daf5e6cef..748a2446059 100644 --- a/packages/uniswap/src/features/tokens/getCurrencyAmount.test.ts +++ b/packages/uniswap/src/features/tokens/getCurrencyAmount.test.ts @@ -3,6 +3,7 @@ import { DAI } from 'uniswap/src/constants/tokens' import { getCurrencyAmount, ValueType } from 'uniswap/src/features/tokens/getCurrencyAmount' import { noOpFunction } from 'utilities/src/test/utils' +const ZERO_DAI = CurrencyAmount.fromRawAmount(DAI, '0') const ONE_DAI = CurrencyAmount.fromRawAmount(DAI, '1000000000000000000') const HALF_DAI = CurrencyAmount.fromRawAmount(DAI, '500000000000000000') const FRACTION_OF_DAI = CurrencyAmount.fromRawAmount(DAI, '1000000000000000') @@ -28,8 +29,12 @@ describe(getCurrencyAmount, () => { expect(getCurrencyAmount({ value: '1000000000', valueType: ValueType.Exact, currency: undefined })).toBeUndefined() }) - it('return null when float value is 0', () => { - expect(getCurrencyAmount({ value: '0', valueType: ValueType.Exact, currency: DAI })).toBeNull() + it('return 0 when float value is 0', () => { + expect(getCurrencyAmount({ value: '0', valueType: ValueType.Exact, currency: DAI })).toEqual(ZERO_DAI) + }) + + it('return undefined when float value is undefined', () => { + expect(getCurrencyAmount({ value: undefined, valueType: ValueType.Exact, currency: DAI })).toBeUndefined() }) it('parse standard float amount', () => { @@ -89,7 +94,7 @@ describe(getCurrencyAmount, () => { valueType: ValueType.Exact, currency: DAI, }), - ).toBeNull() + ).toEqual(ZERO_DAI) }) it('handle invalid values', () => { diff --git a/packages/uniswap/src/features/tokens/getCurrencyAmount.ts b/packages/uniswap/src/features/tokens/getCurrencyAmount.ts index f99e821ed98..6749243df30 100644 --- a/packages/uniswap/src/features/tokens/getCurrencyAmount.ts +++ b/packages/uniswap/src/features/tokens/getCurrencyAmount.ts @@ -40,9 +40,6 @@ export function getCurrencyAmount({ if (valueType === ValueType.Exact) { parsedValue = parseUnits(parsedValue, currency.decimals).toString() - if (parsedValue === '0') { - return null - } } return CurrencyAmount.fromRawAmount(currency, parsedValue) diff --git a/packages/uniswap/src/features/tokens/safetyUtils.test.ts b/packages/uniswap/src/features/tokens/safetyUtils.test.ts index 6ac8a5ea0f3..d5979161f99 100644 --- a/packages/uniswap/src/features/tokens/safetyUtils.test.ts +++ b/packages/uniswap/src/features/tokens/safetyUtils.test.ts @@ -40,9 +40,12 @@ describe('safetyUtils', () => { } as CurrencyInfo describe('getTokenWarningSeverity', () => { - it('should return undefined when currencyInfo is not provided', () => { - expect(getTokenWarningSeverity(undefined)).toBeUndefined() - expect(getTokenWarningSeverity({ ...mockCurrencyInfo, safetyInfo: undefined })).toBeUndefined() + it('should return None when currencyInfo is fully undefined', () => { + expect(getTokenWarningSeverity(undefined)).toBe(WarningSeverity.None) + }) + + it('should return Low when currencyInfo is defined but safetyInfo is undefined', () => { + expect(getTokenWarningSeverity({ ...mockCurrencyInfo, safetyInfo: undefined })).toBe(WarningSeverity.Low) }) it('should return Low for non-default token', () => { diff --git a/packages/uniswap/src/features/tokens/safetyUtils.ts b/packages/uniswap/src/features/tokens/safetyUtils.ts index 016e4d2a598..0ac82cc260a 100644 --- a/packages/uniswap/src/features/tokens/safetyUtils.ts +++ b/packages/uniswap/src/features/tokens/safetyUtils.ts @@ -1,33 +1,32 @@ /* eslint-disable consistent-return */ -import { Currency, NativeCurrency } from '@uniswap/sdk-core' +import { Currency, NativeCurrency, Percent } from '@uniswap/sdk-core' import { TFunction } from 'i18next' import { useTranslation } from 'react-i18next' import { WarningSeverity } from 'uniswap/src/components/modals/WarningModal/types' import { ProtectionResult } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' import { AttackType, CurrencyInfo, TokenList } from 'uniswap/src/features/dataApi/types' import { useLocalizationContext } from 'uniswap/src/features/language/LocalizationContext' -import { FormatNumberOrStringInput } from 'uniswap/src/features/language/formatter' -import { NumberType } from 'utilities/src/format/types' import { isInterface } from 'utilities/src/platform' export enum TokenProtectionWarning { - MaliciousHoneypot = 'malicious-honeypot', // 100% fot - MaliciousImpersonator = 'malicious-impersonator', - SpamAirdrop = 'spam-airdrop', - MaliciousGeneral = 'malicious-general', - FotVeryHigh = 'fot-very-high', // [80, 100)% fot - FotHigh = 'fot-high', // [5, 80)% fot - FotLow = 'fot-low', // (0, 5)% fot - Blocked = 'blocked', - NonDefault = 'non-default', - None = 'none', + // THESE NUMERIC VALUES MATTER -- they are used for severity comparison + Blocked = 10, + MaliciousHoneypot = 9, // 100% fot + FotVeryHigh = 8, // [80, 100)% fot + MaliciousImpersonator = 7, + FotHigh = 6, // [5, 80)% fot + MaliciousGeneral = 5, + SpamAirdrop = 4, + FotLow = 3, // (0, 5)% fot + NonDefault = 2, + None = 1, } export const TOKEN_PROTECTION_FOT_HONEYPOT_BREAKPOINT = 100 export const TOKEN_PROTECTION_FOT_HIGH_FEE_BREAKPOINT = 80 export const TOKEN_PROTECTION_FOT_FEE_BREAKPOINT = 5 -function getFeeOnTransfer(currency?: Currency): number { +export function getFeeOnTransfer(currency?: Currency): number { if (!currency || currency.isNative) { return 0 } @@ -37,9 +36,9 @@ function getFeeOnTransfer(currency?: Currency): number { } // eslint-disable-next-line complexity -function getTokenProtectionWarning(currencyInfo?: Maybe): TokenProtectionWarning | undefined { +export function getTokenProtectionWarning(currencyInfo?: Maybe): TokenProtectionWarning { if (!currencyInfo?.currency || !currencyInfo?.safetyInfo) { - return undefined + return TokenProtectionWarning.NonDefault } const { currency, safetyInfo } = currencyInfo @@ -61,13 +60,13 @@ function getTokenProtectionWarning(currencyInfo?: Maybe): TokenPro attackType === AttackType.HighFees) ) { return TokenProtectionWarning.FotVeryHigh - } else if (feeOnTransfer >= TOKEN_PROTECTION_FOT_FEE_BREAKPOINT) { - return TokenProtectionWarning.FotHigh } else if ( (protectionResult === ProtectionResult.Malicious || protectionResult === ProtectionResult.Spam) && attackType === AttackType.Impersonator ) { return TokenProtectionWarning.MaliciousImpersonator + } else if (feeOnTransfer >= TOKEN_PROTECTION_FOT_FEE_BREAKPOINT) { + return TokenProtectionWarning.FotHigh } else if ( (protectionResult === ProtectionResult.Malicious || protectionResult === ProtectionResult.Spam) && attackType === AttackType.Other @@ -84,22 +83,41 @@ function getTokenProtectionWarning(currencyInfo?: Maybe): TokenPro return TokenProtectionWarning.None } -export function getIsFeeRelatedWarning(currencyInfo?: CurrencyInfo): boolean { - const warning = getTokenProtectionWarning(currencyInfo) +export function getIsFeeRelatedWarning(tokenProtectionWarning?: TokenProtectionWarning): boolean { return ( - warning === TokenProtectionWarning.MaliciousHoneypot || - warning === TokenProtectionWarning.FotVeryHigh || - warning === TokenProtectionWarning.FotHigh || - warning === TokenProtectionWarning.FotLow + tokenProtectionWarning === TokenProtectionWarning.MaliciousHoneypot || + tokenProtectionWarning === TokenProtectionWarning.FotVeryHigh || + tokenProtectionWarning === TokenProtectionWarning.FotHigh || + tokenProtectionWarning === TokenProtectionWarning.FotLow ) } -export function getTokenWarningSeverity(currencyInfo: Maybe): WarningSeverity | undefined { - const tokenProtectionWarning = getTokenProtectionWarning(currencyInfo) - if (!currencyInfo || tokenProtectionWarning === undefined) { - return undefined +export function getFeeWarning(fee: Percent): TokenProtectionWarning { + // WarningSeverity for styling. Same logic as getTokenWarningSeverity but without non-fee-related cases. + // If fee >= 5% then HIGH, else 0% < fee < 5% then MEDIUM, else NONE + const feeInt = parseFloat(fee.toFixed()) + let tokenProtectionWarning = TokenProtectionWarning.None + if (feeInt >= TOKEN_PROTECTION_FOT_HONEYPOT_BREAKPOINT) { + tokenProtectionWarning = TokenProtectionWarning.MaliciousHoneypot + } else if (feeInt >= TOKEN_PROTECTION_FOT_HIGH_FEE_BREAKPOINT) { + tokenProtectionWarning = TokenProtectionWarning.FotVeryHigh + } else if (feeInt >= TOKEN_PROTECTION_FOT_FEE_BREAKPOINT) { + tokenProtectionWarning = TokenProtectionWarning.FotHigh + } else if (feeInt >= 0) { + tokenProtectionWarning = TokenProtectionWarning.FotLow } + return tokenProtectionWarning +} + +export function getTokenWarningSeverity(currencyInfo: Maybe): WarningSeverity { + if (!currencyInfo) { + return WarningSeverity.None + } + const tokenProtectionWarning = getTokenProtectionWarning(currencyInfo) + return getSeverityFromTokenProtectionWarning(tokenProtectionWarning) +} +export function getSeverityFromTokenProtectionWarning(tokenProtectionWarning: TokenProtectionWarning): WarningSeverity { switch (tokenProtectionWarning) { case TokenProtectionWarning.Blocked: return WarningSeverity.Blocked @@ -160,11 +178,14 @@ export function getModalHeaderText({ shouldHavePluralTreatment, }: { t: TFunction - tokenProtectionWarning: TokenProtectionWarning + tokenProtectionWarning?: TokenProtectionWarning tokenSymbol0?: string tokenSymbol1?: string shouldHavePluralTreatment?: boolean }): string | null { + if (!tokenProtectionWarning) { + return null + } switch (tokenProtectionWarning) { case TokenProtectionWarning.Blocked: return shouldHavePluralTreatment @@ -198,21 +219,41 @@ export function useModalSubtitleText(currencyInfo0: CurrencyInfo, currencyInfo1? throw new Error('Should only combine into one plural-languaged modal if BOTH are low or BOTH are blocked') } const { t } = useTranslation() - const { formatNumberOrString } = useLocalizationContext() - + const { formatPercent } = useLocalizationContext() const tokenProtectionWarning = getTokenProtectionWarning(currencyInfo0) - const tokenList = currencyInfo0.safetyInfo?.tokenList + return getModalSubtitleText({ + t, + tokenProtectionWarning, + tokenSymbol: currencyInfo0.currency.symbol, + tokenList: currencyInfo0.safetyInfo?.tokenList, + feePercent: getFeeOnTransfer(currencyInfo0.currency), + shouldHavePluralTreatment, + formatPercent, + }) +} +export function getModalSubtitleText({ + t, + tokenProtectionWarning, + tokenSymbol, + tokenList, + feePercent, + shouldHavePluralTreatment, + formatPercent, +}: { + t: TFunction + tokenProtectionWarning: TokenProtectionWarning | undefined + tokenSymbol?: string + tokenList?: TokenList + feePercent: number + shouldHavePluralTreatment?: boolean + formatPercent: (value: Maybe) => string +}): string | null { if (!tokenProtectionWarning) { return null } - const formattedFeePercent = formatNumberOrString({ - value: getFeeOnTransfer(currencyInfo0.currency) / 100, - type: NumberType.Percentage, - }) - - const tokenSymbol = currencyInfo0.currency?.symbol + const formattedFeePercent = formatPercent(feePercent) const warningCopy = getModalSubtitleTokenWarningText({ t, tokenProtectionWarning, @@ -289,25 +330,37 @@ export function getModalSubtitleTokenWarningText({ } export function useTokenWarningCardText(currencyInfo: Maybe): { - heading?: string + heading: string | null description: string | null } { const { t } = useTranslation() - const { formatNumberOrString } = useLocalizationContext() + const { formatPercent } = useLocalizationContext() + if (!currencyInfo) { + return { + heading: null, + description: null, + } + } const tokenProtectionWarning = getTokenProtectionWarning(currencyInfo) return { heading: getCardHeaderText({ t, tokenProtectionWarning }), - description: getCardSubtitleText({ t, currencyInfo, tokenProtectionWarning, formatNumberOrString }), + description: getCardSubtitleText({ + t, + tokenProtectionWarning, + tokenSymbol: currencyInfo.currency.symbol, + feePercent: getFeeOnTransfer(currencyInfo.currency), + formatPercent, + }), } } -function getCardHeaderText({ +export function getCardHeaderText({ t, tokenProtectionWarning, }: { t: TFunction - tokenProtectionWarning?: TokenProtectionWarning -}): string | undefined { + tokenProtectionWarning: TokenProtectionWarning +}): string | null { switch (tokenProtectionWarning) { case TokenProtectionWarning.Blocked: return t('token.safetyLevel.blocked.header') @@ -323,28 +376,26 @@ function getCardHeaderText({ return t('token.safety.warning.highFeeDetected.title') case TokenProtectionWarning.FotLow: return t('token.safety.warning.feeDetected.title') + case TokenProtectionWarning.NonDefault: case TokenProtectionWarning.None: - default: - return undefined + return null } } -function getCardSubtitleText({ - currencyInfo, - tokenProtectionWarning, +export function getCardSubtitleText({ t, - formatNumberOrString, + tokenProtectionWarning, + tokenSymbol, + feePercent, + formatPercent, }: { t: TFunction - currencyInfo: Maybe - tokenProtectionWarning?: TokenProtectionWarning - formatNumberOrString: (input: FormatNumberOrStringInput) => string + tokenProtectionWarning: TokenProtectionWarning + tokenSymbol?: string + feePercent: number + formatPercent: (value: Maybe) => string }): string | null { - const feePercent: string = formatNumberOrString({ - value: getFeeOnTransfer(currencyInfo?.currency) / 100, - type: NumberType.Percentage, - }) - const tokenSymbol = currencyInfo?.currency?.symbol + const formattedFeePercent: string = formatPercent(feePercent) switch (tokenProtectionWarning) { case TokenProtectionWarning.Blocked: return isInterface @@ -359,13 +410,11 @@ function getCardSubtitleText({ return t('token.safety.warning.spam.message', { tokenSymbol }) case TokenProtectionWarning.FotVeryHigh: case TokenProtectionWarning.FotHigh: - return t('token.safety.warning.tokenChargesFee.percent.message', { tokenSymbol, feePercent }) case TokenProtectionWarning.FotLow: - return t('token.safety.warning.tokenChargesFee.message') + return t('token.safety.warning.tokenChargesFee.percent.message', { tokenSymbol, feePercent: formattedFeePercent }) case TokenProtectionWarning.NonDefault: - return t('token.safety.warning.medium.heading.default_one') + return t('token.safety.warning.medium.heading.named', { tokenSymbol }) case TokenProtectionWarning.None: return null } - return null } diff --git a/packages/uniswap/src/features/transactions/DecimalPadInput/DecimalPadInput.tsx b/packages/uniswap/src/features/transactions/DecimalPadInput/DecimalPadInput.tsx index bb641afc7cf..3520bb10c5c 100644 --- a/packages/uniswap/src/features/transactions/DecimalPadInput/DecimalPadInput.tsx +++ b/packages/uniswap/src/features/transactions/DecimalPadInput/DecimalPadInput.tsx @@ -9,7 +9,7 @@ import { useRef, useState, } from 'react' -import { Flex } from 'ui/src' +import { Flex, useIsShortMobileDevice } from 'ui/src' import { TextInputProps } from 'uniswap/src/components/input/TextInput' import { DecimalPad } from 'uniswap/src/features/transactions/DecimalPadInput/DecimalPad' // eslint-disable-next-line no-restricted-imports -- type import is safe @@ -39,6 +39,14 @@ export type DecimalPadInputRef = { setMaxHeight(height: number): void } +export enum DecimalPadCalculatedSpaceId { + Swap, + Send, + FiatOnRamp, +} + +const precalculatedSpace: Partial> = {} + /* This component is used to calculate the space that the `DecimalPad` can use. We position the `DecimalPad` with `position: absolute` at the bottom of the screen instead of @@ -46,19 +54,35 @@ putting it inside this container in order to avoid any overflows while the `Deci is automatically resizing to find the right size for the screen. */ export function DecimalPadCalculateSpace({ - isShortMobileDevice, + id, decimalPadRef, }: { - isShortMobileDevice: boolean + id: DecimalPadCalculatedSpaceId decimalPadRef: RefObject }): JSX.Element { + const isShortMobileDevice = useIsShortMobileDevice() + const onBottomScreenLayout = useCallback( (event: LayoutChangeEvent): void => { - decimalPadRef.current?.setMaxHeight(event.nativeEvent.layout.height) + const height = event.nativeEvent.layout.height + decimalPadRef.current?.setMaxHeight(height) + precalculatedSpace[id] = height }, - [decimalPadRef], + [decimalPadRef, id], ) + useEffect(() => { + const precalculatedHeight = precalculatedSpace[id] + + if (precalculatedHeight) { + // If we have already rendered this screen, we already know how much space this phone has, + // so we optimistically set the height instead of waiting for the layout event. + // This improves the perceived loading time of the `DecimalPad`, + // given that it fades in only after the height is known. + decimalPadRef.current?.setMaxHeight(precalculatedHeight) + } + }, [decimalPadRef, id]) + return } @@ -83,7 +107,7 @@ export const DecimalPadInput = memo( useEffect(() => { updateDisabledKeys(valueRef.current) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [valueRef, selectionRef]) + }, [valueRef, selectionRef, maxDecimals]) useImperativeHandle(ref, () => ({ updateDisabledKeys(): void { diff --git a/packages/uniswap/src/features/transactions/TransactionDetails/FeeOnTransferFee.tsx b/packages/uniswap/src/features/transactions/TransactionDetails/FeeOnTransferFee.tsx index e48ec70ac93..00202f35198 100644 --- a/packages/uniswap/src/features/transactions/TransactionDetails/FeeOnTransferFee.tsx +++ b/packages/uniswap/src/features/transactions/TransactionDetails/FeeOnTransferFee.tsx @@ -1,62 +1,14 @@ -import { Percent } from '@uniswap/sdk-core' import { useTranslation } from 'react-i18next' import { Flex, Text } from 'ui/src' -import { WarningSeverity } from 'uniswap/src/components/modals/WarningModal/types' import WarningIcon from 'uniswap/src/components/warnings/WarningIcon' import { - TOKEN_PROTECTION_FOT_FEE_BREAKPOINT, - TOKEN_PROTECTION_FOT_HIGH_FEE_BREAKPOINT, - TOKEN_PROTECTION_FOT_HONEYPOT_BREAKPOINT, - TokenProtectionWarning, -} from 'uniswap/src/features/tokens/safetyUtils' + FeeOnTransferFeeGroupProps, + FoTFeeType, + TokenFeeInfo, +} from 'uniswap/src/features/transactions/TransactionDetails/types' +import { getFeeSeverity } from 'uniswap/src/features/transactions/TransactionDetails/utils' import { FeeOnTransferWarning } from 'uniswap/src/features/transactions/swap/modals/FeeOnTransferWarning' -export type FeeOnTransferFeeGroupProps = { - inputTokenInfo: TokenFeeInfo - outputTokenInfo: TokenFeeInfo -} - -export type TokenFeeInfo = { - tokenSymbol: string - fee: Percent - formattedUsdAmount: string -} - -export function getFeeSeverity(fee: Percent): { - severity: WarningSeverity - tokenProtectionWarning: TokenProtectionWarning -} { - // WarningSeverity for styling. Same logic as getTokenWarningSeverity but without non-fee-related cases. - // If fee >= 5% then HIGH, else 0% < fee < 5% then MEDIUM, else NONE - const feeInt = parseFloat(fee.toFixed()) - if (feeInt >= TOKEN_PROTECTION_FOT_HONEYPOT_BREAKPOINT) { - return { - severity: WarningSeverity.High, - tokenProtectionWarning: TokenProtectionWarning.MaliciousHoneypot, - } - } else if (feeInt >= TOKEN_PROTECTION_FOT_HIGH_FEE_BREAKPOINT) { - return { - severity: WarningSeverity.High, - tokenProtectionWarning: TokenProtectionWarning.FotVeryHigh, - } - } else if (feeInt >= TOKEN_PROTECTION_FOT_FEE_BREAKPOINT) { - return { - severity: WarningSeverity.High, - tokenProtectionWarning: TokenProtectionWarning.FotHigh, - } - } else if (feeInt >= 0) { - return { - severity: WarningSeverity.Medium, - tokenProtectionWarning: TokenProtectionWarning.FotLow, - } - } else { - return { - severity: WarningSeverity.None, - tokenProtectionWarning: TokenProtectionWarning.None, - } - } -} - export function FeeOnTransferFeeGroup({ inputTokenInfo, outputTokenInfo, @@ -65,21 +17,24 @@ export function FeeOnTransferFeeGroup({ return null } + // The input token is the one you're selling, therefore it would have a sell fee + // The output token is the one you're buying, therefore it would have a buy fee return ( - - {inputTokenInfo.fee.greaterThan(0) && } - {outputTokenInfo.fee.greaterThan(0) && } + + {inputTokenInfo.fee.greaterThan(0) && } + {outputTokenInfo.fee.greaterThan(0) && } ) } -function FeeOnTransferFeeRow({ feeInfo }: { feeInfo: TokenFeeInfo }): JSX.Element { +function FeeOnTransferFeeRow({ feeType, feeInfo }: { feeType: FoTFeeType; feeInfo: TokenFeeInfo }): JSX.Element { const { t } = useTranslation() const { severity } = getFeeSeverity(feeInfo.fee) + const usdAmountLoading = feeInfo.formattedUsdAmount === '-' return ( - + {t('swap.details.feeOnTransfer', { tokenSymbol: feeInfo.tokenSymbol })} @@ -87,7 +42,7 @@ function FeeOnTransferFeeRow({ feeInfo }: { feeInfo: TokenFeeInfo }): JSX.Elemen - {feeInfo.formattedUsdAmount} + {usdAmountLoading ? `${feeInfo.formattedAmount} ${feeInfo.tokenSymbol}` : feeInfo.formattedUsdAmount} diff --git a/packages/uniswap/src/features/transactions/TransactionDetails/FeeOnTransferWarningCard.tsx b/packages/uniswap/src/features/transactions/TransactionDetails/FeeOnTransferWarningCard.tsx deleted file mode 100644 index 77fd23b2edc..00000000000 --- a/packages/uniswap/src/features/transactions/TransactionDetails/FeeOnTransferWarningCard.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { useTranslation } from 'react-i18next' -import { InlineWarningCard } from 'uniswap/src/components/InlineWarningCard/InlineWarningCard' -import { WarningSeverity } from 'uniswap/src/components/modals/WarningModal/types' -import { useLocalizationContext } from 'uniswap/src/features/language/LocalizationContext' -import { - FeeOnTransferFeeGroupProps, - getFeeSeverity, -} from 'uniswap/src/features/transactions/TransactionDetails/FeeOnTransferFee' - -type FeeOnTransferWarningCardProps = { - checked: boolean - setChecked: (checked: boolean) => void -} & FeeOnTransferFeeGroupProps - -export function FeeOnTransferWarningCard({ - inputTokenInfo, - outputTokenInfo, - checked, - setChecked, -}: FeeOnTransferWarningCardProps): JSX.Element | null { - const { t } = useTranslation() - const { formatPercent } = useLocalizationContext() - - // Don't show warning card if neither token is FOT - if (!inputTokenInfo.fee.greaterThan(0) && !outputTokenInfo.fee.greaterThan(0)) { - return null - } - - const highestFeeTokenInfo = inputTokenInfo.fee.greaterThan(outputTokenInfo.fee) ? inputTokenInfo : outputTokenInfo - const { severity: feeSeverity } = getFeeSeverity(highestFeeTokenInfo.fee) - - // Only show the warning card if the fee is HIGH severity - if (feeSeverity !== WarningSeverity.High) { - return null - } - - return ( - - ) -} diff --git a/packages/uniswap/src/features/transactions/TransactionDetails/SwapReviewTokenWarningCard.tsx b/packages/uniswap/src/features/transactions/TransactionDetails/SwapReviewTokenWarningCard.tsx new file mode 100644 index 00000000000..2fa5e9120be --- /dev/null +++ b/packages/uniswap/src/features/transactions/TransactionDetails/SwapReviewTokenWarningCard.tsx @@ -0,0 +1,55 @@ +import { TokenWarningCard } from 'uniswap/src/features/tokens/TokenWarningCard' +import { getIsFeeRelatedWarning } from 'uniswap/src/features/tokens/safetyUtils' +import { + FeeOnTransferFeeGroupProps, + TokenWarningProps, +} from 'uniswap/src/features/transactions/TransactionDetails/types' +import { + getHighestFeeSeverity, + getShouldDisplayTokenWarningCard, +} from 'uniswap/src/features/transactions/TransactionDetails/utils' + +type FeeOnTransferWarningCardProps = { + checked: boolean + setChecked: (checked: boolean) => void + feeOnTransferProps?: FeeOnTransferFeeGroupProps + tokenWarningProps: TokenWarningProps +} + +export function SwapReviewTokenWarningCard({ + feeOnTransferProps, + tokenWarningProps, + checked, + setChecked, +}: FeeOnTransferWarningCardProps): JSX.Element | null { + const { currencyInfo, severity, tokenProtectionWarning } = tokenWarningProps + const { + severity: feeSeverity, + tokenProtectionWarning: feeWarning, + highestFeeTokenInfo, + } = getHighestFeeSeverity(feeOnTransferProps) + + const showFeeSeverityWarning = getIsFeeRelatedWarning(tokenProtectionWarning) + const { shouldDisplayTokenWarningCard, tokenProtectionWarningToDisplay } = getShouldDisplayTokenWarningCard({ + severity, + tokenProtectionWarning, + feeSeverity, + feeWarning, + }) + const feePercent = highestFeeTokenInfo ? parseFloat(highestFeeTokenInfo.fee.toFixed()) : undefined + + if (!shouldDisplayTokenWarningCard || (showFeeSeverityWarning && !highestFeeTokenInfo)) { + return null + } + + return ( + + ) +} diff --git a/packages/uniswap/src/features/transactions/TransactionDetails/TransactionDetails.tsx b/packages/uniswap/src/features/transactions/TransactionDetails/TransactionDetails.tsx index 07de89cd7e8..8f8cf61b276 100644 --- a/packages/uniswap/src/features/transactions/TransactionDetails/TransactionDetails.tsx +++ b/packages/uniswap/src/features/transactions/TransactionDetails/TransactionDetails.tsx @@ -10,13 +10,16 @@ import { NetworkFee } from 'uniswap/src/components/gas/NetworkFee' import { getAlertColor } from 'uniswap/src/components/modals/WarningModal/getAlertColor' import { Warning } from 'uniswap/src/components/modals/WarningModal/types' import { GasFeeResult } from 'uniswap/src/features/gas/types' +import { FeatureFlags } from 'uniswap/src/features/gating/flags' +import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { FeeOnTransferFeeGroup } from 'uniswap/src/features/transactions/TransactionDetails/FeeOnTransferFee' +import { SwapFee } from 'uniswap/src/features/transactions/TransactionDetails/SwapFee' +import { SwapReviewTokenWarningCard } from 'uniswap/src/features/transactions/TransactionDetails/SwapReviewTokenWarningCard' import { - FeeOnTransferFeeGroup, FeeOnTransferFeeGroupProps, -} from 'uniswap/src/features/transactions/TransactionDetails/FeeOnTransferFee' -import { FeeOnTransferWarningCard } from 'uniswap/src/features/transactions/TransactionDetails/FeeOnTransferWarningCard' -import { SwapFee } from 'uniswap/src/features/transactions/TransactionDetails/SwapFee' + TokenWarningProps, +} from 'uniswap/src/features/transactions/TransactionDetails/types' import { EstimatedTime } from 'uniswap/src/features/transactions/swap/review/EstimatedTime' import { UniswapXGasBreakdown } from 'uniswap/src/features/transactions/swap/types/swapTxAndGasInfo' import { SwapFee as SwapFeeType } from 'uniswap/src/features/transactions/swap/types/trade' @@ -36,8 +39,9 @@ interface TransactionDetailsProps { showSeparatorToggle?: boolean warning?: Warning feeOnTransferProps?: FeeOnTransferFeeGroupProps - feeOnTransferWarningChecked?: boolean - setFeeOnTransferWarningChecked?: (checked: boolean) => void + tokenWarningProps?: TokenWarningProps + tokenWarningChecked?: boolean + setTokenWarningChecked?: (checked: boolean) => void outputCurrency?: Currency onShowWarning?: () => void indicative?: boolean @@ -65,8 +69,9 @@ export function TransactionDetails({ showWarning, warning, feeOnTransferProps, - feeOnTransferWarningChecked, - setFeeOnTransferWarningChecked, + tokenWarningProps, + tokenWarningChecked, + setTokenWarningChecked, onShowWarning, indicative = false, isSwap, @@ -78,9 +83,7 @@ export function TransactionDetails({ RateInfo, }: PropsWithChildren): JSX.Element { const { t } = useTranslation() - const showFeeOnTransferWarningCard = - !!feeOnTransferProps && !feeOnTransferWarningChecked && !!setFeeOnTransferWarningChecked - + const tokenProtectionEnabled = useFeatureFlag(FeatureFlags.TokenProtection) const [showChildren, setShowChildren] = useState(showExpandedChildren) const onPressToggleShowChildren = (): void => { @@ -127,11 +130,12 @@ export function TransactionDetails({ ) : null} - {showFeeOnTransferWarningCard && ( - )} diff --git a/packages/uniswap/src/features/transactions/TransactionDetails/types.ts b/packages/uniswap/src/features/transactions/TransactionDetails/types.ts new file mode 100644 index 00000000000..20b39c59769 --- /dev/null +++ b/packages/uniswap/src/features/transactions/TransactionDetails/types.ts @@ -0,0 +1,25 @@ +import { Percent } from '@uniswap/sdk-core' +import { WarningSeverity } from 'uniswap/src/components/modals/WarningModal/types' +import { CurrencyInfo } from 'uniswap/src/features/dataApi/types' +import { TokenProtectionWarning } from 'uniswap/src/features/tokens/safetyUtils' + +export type FoTFeeType = 'buy' | 'sell' + +export type FeeOnTransferFeeGroupProps = { + inputTokenInfo: TokenFeeInfo + outputTokenInfo: TokenFeeInfo +} + +export type TokenFeeInfo = { + currencyInfo: Maybe + tokenSymbol: string + fee: Percent + formattedUsdAmount: string + formattedAmount: string +} + +export type TokenWarningProps = { + currencyInfo: Maybe + tokenProtectionWarning: TokenProtectionWarning + severity: WarningSeverity +} diff --git a/packages/uniswap/src/features/transactions/TransactionDetails/utils.ts b/packages/uniswap/src/features/transactions/TransactionDetails/utils.ts new file mode 100644 index 00000000000..a82d0f62d6d --- /dev/null +++ b/packages/uniswap/src/features/transactions/TransactionDetails/utils.ts @@ -0,0 +1,87 @@ +import { Percent } from '@uniswap/sdk-core' +import { WarningSeverity } from 'uniswap/src/components/modals/WarningModal/types' +import { CurrencyInfo } from 'uniswap/src/features/dataApi/types' +import { + TokenProtectionWarning, + getFeeWarning, + getIsFeeRelatedWarning, + getSeverityFromTokenProtectionWarning, + getTokenProtectionWarning, +} from 'uniswap/src/features/tokens/safetyUtils' +import { + FeeOnTransferFeeGroupProps, + TokenFeeInfo, + TokenWarningProps, +} from 'uniswap/src/features/transactions/TransactionDetails/types' +import { DerivedSwapInfo } from 'uniswap/src/features/transactions/swap/types/derivedSwapInfo' + +export function getFeeSeverity(fee: Percent): { + severity: WarningSeverity + tokenProtectionWarning: TokenProtectionWarning +} { + // WarningSeverity for styling. Same logic as getTokenWarningSeverity but without non-fee-related cases. + // If fee >= 5% then HIGH, else 0% < fee < 5% then MEDIUM, else NONE + const tokenProtectionWarning = getFeeWarning(fee) + const severity = getSeverityFromTokenProtectionWarning(tokenProtectionWarning) + return { severity, tokenProtectionWarning } +} + +export function getHighestFeeSeverity(feeOnTransferProps: FeeOnTransferFeeGroupProps | undefined): { + highestFeeTokenInfo?: TokenFeeInfo + tokenProtectionWarning: TokenProtectionWarning + severity: WarningSeverity +} { + if (!feeOnTransferProps) { + return { severity: WarningSeverity.None, tokenProtectionWarning: TokenProtectionWarning.None } + } + + const { inputTokenInfo, outputTokenInfo } = feeOnTransferProps + if (!inputTokenInfo.fee.greaterThan(0) && !outputTokenInfo.fee.greaterThan(0)) { + return { severity: WarningSeverity.None, tokenProtectionWarning: TokenProtectionWarning.None } + } + + const highestFeeTokenInfo = inputTokenInfo.fee.greaterThan(outputTokenInfo.fee) ? inputTokenInfo : outputTokenInfo + return { highestFeeTokenInfo, ...getFeeSeverity(highestFeeTokenInfo.fee) } +} + +export function getShouldDisplayTokenWarningCard({ + feeSeverity, + feeWarning, + severity, + tokenProtectionWarning, +}: { + severity: WarningSeverity + tokenProtectionWarning: TokenProtectionWarning + feeSeverity: WarningSeverity + feeWarning: TokenProtectionWarning +}): { + shouldDisplayTokenWarningCard: boolean + severityToDisplay: WarningSeverity + tokenProtectionWarningToDisplay: TokenProtectionWarning +} { + const feeWarningMoreSevere = feeWarning > tokenProtectionWarning + const showFeeSeverityWarning = + feeWarningMoreSevere || (getIsFeeRelatedWarning(tokenProtectionWarning) && feeSeverity === severity) + const severityToDisplay = showFeeSeverityWarning ? feeSeverity : severity + const tokenProtectionWarningToDisplay = showFeeSeverityWarning ? feeWarning : tokenProtectionWarning + return { + shouldDisplayTokenWarningCard: severityToDisplay === WarningSeverity.High, + severityToDisplay, + tokenProtectionWarningToDisplay, + } +} + +export function getRelevantTokenWarningSeverity( + acceptedDerivedSwapInfo?: DerivedSwapInfo, +): TokenWarningProps { + // New logic is to only ever show the outputWarning in a TokenWarningCard on SwapReview. Keeping this helper function for convenience. + const outputCurrency = acceptedDerivedSwapInfo?.currencies.output + const outputWarning = getTokenProtectionWarning(outputCurrency) + const outputSeverity = getSeverityFromTokenProtectionWarning(outputWarning) + + return { + currencyInfo: outputCurrency, + tokenProtectionWarning: outputWarning, + severity: outputSeverity, + } +} diff --git a/packages/uniswap/src/features/transactions/errors.tsx b/packages/uniswap/src/features/transactions/errors.tsx index 666de5226bb..8c46d5295e4 100644 --- a/packages/uniswap/src/features/transactions/errors.tsx +++ b/packages/uniswap/src/features/transactions/errors.tsx @@ -1,12 +1,19 @@ import { AppTFunction } from 'ui/src/i18n/types' import { uniswapUrls } from 'uniswap/src/constants/urls' import { FetchError } from 'uniswap/src/data/apiClients/FetchError' -import { TransactionStep, TransactionStepType } from 'uniswap/src/features/transactions/swap/types/steps' +import { + TokenApprovalTransactionStep, + TokenRevocationTransactionStep, + TransactionStep, + TransactionStepType, +} from 'uniswap/src/features/transactions/swap/types/steps' import { Sentry } from 'utilities/src/logger/Sentry' import { OverridesSentryFingerprint } from 'utilities/src/logger/types' /** Superclass used to differentiate categorized/known transaction errors from generic/unknown errors. */ -export abstract class TransactionError extends Error {} +export abstract class TransactionError extends Error { + logToSentry = true +} /** Thrown in code paths that should be unreachable, serving both typechecking and critical alarm purposes. */ export class UnexpectedTransactionStateError extends TransactionError { @@ -85,6 +92,14 @@ export class TransactionStepFailedError extends TransactionError implements Over } } +export class ApprovalEditedInWalletError extends TransactionStepFailedError { + logToSentry = false + + constructor({ step }: { step: TokenApprovalTransactionStep | TokenRevocationTransactionStep }) { + super({ message: 'Approval decreased to insufficient amount in wallet', step }) + } +} + /** Thrown when a transaction flow is interrupted by a known circumstance; should be handled gracefully in UI */ export class HandledTransactionInterrupt extends TransactionError { constructor(message: string) { @@ -154,6 +169,13 @@ function getStepSpecificErrorContent( supportArticleURL: uniswapUrls.helpArticleUrls.approvalsExplainer, } case TransactionStepType.TokenApprovalTransaction: + if (error instanceof ApprovalEditedInWalletError) { + return { + title: t('error.tokenApprovalEdited'), + message: t('error.tokenApprovalEdited.message'), + supportArticleURL: uniswapUrls.helpArticleUrls.approvalsExplainer, + } + } return { title: t('error.tokenApproval'), message: t('error.access.expiry'), diff --git a/packages/uniswap/src/features/transactions/hooks/useUSDTokenUpdater.ts b/packages/uniswap/src/features/transactions/hooks/useUSDTokenUpdater.ts index 34712831895..647fa821835 100644 --- a/packages/uniswap/src/features/transactions/hooks/useUSDTokenUpdater.ts +++ b/packages/uniswap/src/features/transactions/hooks/useUSDTokenUpdater.ts @@ -39,7 +39,7 @@ export function useUSDTokenUpdater({ return undefined } - const exactAmountUSD = (parseFloat(exactAmountFiat) / conversionRate).toFixed(NUM_DECIMALS_USD) + const exactAmountUSD = (parseFloat(exactAmountFiat || '0') / conversionRate).toFixed(NUM_DECIMALS_USD) if (shouldUseUSDRef.current) { const stablecoinAmount = getCurrencyAmount({ diff --git a/packages/uniswap/src/features/transactions/liquidity/types.ts b/packages/uniswap/src/features/transactions/liquidity/types.ts index 5422b9578a6..8385c638455 100644 --- a/packages/uniswap/src/features/transactions/liquidity/types.ts +++ b/packages/uniswap/src/features/transactions/liquidity/types.ts @@ -1,24 +1,29 @@ // eslint-disable-next-line no-restricted-imports import { ProtocolVersion } from '@uniswap/client-pools/dist/pools/v1/types_pb' -import { Currency, CurrencyAmount, NativeCurrency, Token } from '@uniswap/sdk-core' -import { IncreaseLPPositionRequest } from 'uniswap/src/data/tradingApi/__generated__' +import { Currency, CurrencyAmount, Token } from '@uniswap/sdk-core' +import { + CreateLPPositionRequest, + IncreaseLPPositionRequest, + MigrateLPPositionRequest, +} from 'uniswap/src/data/tradingApi/__generated__' import { ValidatedPermit, ValidatedTransactionRequest } from 'uniswap/src/features/transactions/swap/utils/trade' export interface LiquidityAction { currency0Amount: CurrencyAmount currency1Amount: CurrencyAmount liquidityToken?: Token - nativeCurrencyAmount?: CurrencyAmount } export type LiquidityTxAndGasInfo = | IncreasePositionTxAndGasInfo | DecreasePositionTxAndGasInfo | CreatePositionTxAndGasInfo + | MigrateV3PositionTxAndGasInfo export type ValidatedLiquidityTxContext = | ValidatedIncreasePositionTxAndGasInfo | ValidatedDecreasePositionTxAndGasInfo | ValidatedCreatePositionTxAndGasInfo + | ValidatedMigrateV3PositionTxAndGasInfo export function isValidLiquidityTxContext( liquidityTxContext: LiquidityTxAndGasInfo | unknown, @@ -51,8 +56,12 @@ export interface DecreasePositionTxAndGasInfo extends BaseLiquidityTxAndGasInfo export interface CreatePositionTxAndGasInfo extends BaseLiquidityTxAndGasInfo { type: 'create' unsigned: boolean - createPositionRequestArgs: IncreaseLPPositionRequest | undefined - wrapTxRequest: ValidatedTransactionRequest | undefined + createPositionRequestArgs: CreateLPPositionRequest | undefined +} + +export interface MigrateV3PositionTxAndGasInfo extends BaseLiquidityTxAndGasInfo { + type: 'migrate' + migratePositionRequestArgs: MigrateLPPositionRequest | undefined } export type ValidatedIncreasePositionTxAndGasInfo = Required & @@ -87,6 +96,20 @@ export type ValidatedCreatePositionTxAndGasInfo = Required & + ( + | { + unsigned: true + permit: ValidatedPermit + txRequest: undefined + } + | { + unsigned: false + permit: undefined + txRequest: ValidatedTransactionRequest + } + ) + function validateLiquidityTxContext( liquidityTxContext: LiquidityTxAndGasInfo | unknown, ): ValidatedLiquidityTxContext | undefined { diff --git a/packages/uniswap/src/features/transactions/slice.ts b/packages/uniswap/src/features/transactions/slice.ts index 3bf3e1031fa..57387e5b42c 100644 --- a/packages/uniswap/src/features/transactions/slice.ts +++ b/packages/uniswap/src/features/transactions/slice.ts @@ -45,12 +45,15 @@ const slice = createSlice({ state[from]![chainId]![id] = transaction }, finalizeTransaction: (state, { payload: transaction }: PayloadAction) => { - const { chainId, id, status, receipt, from, hash } = transaction + const { chainId, id, status, receipt, from, hash, networkFee } = transaction assert(state?.[from]?.[chainId]?.[id], `finalizeTransaction: Attempted to finalize a missing tx with id ${id}`) state[from]![chainId]![id]!.status = status if (receipt) { state[from]![chainId]![id]!.receipt = receipt } + if (networkFee) { + state[from]![chainId]![id]!.networkFee = networkFee + } if (isUniswapX(transaction) && status === TransactionStatus.Success) { assert(hash, `finalizeTransaction: Attempted to finalize an order without providing the fill tx hash`) state[from]![chainId]![id]!.hash = hash diff --git a/packages/uniswap/src/features/transactions/swap/form/SwapFormScreen.tsx b/packages/uniswap/src/features/transactions/swap/form/SwapFormScreen.tsx index d8812b906d0..a6b8651f0cd 100644 --- a/packages/uniswap/src/features/transactions/swap/form/SwapFormScreen.tsx +++ b/packages/uniswap/src/features/transactions/swap/form/SwapFormScreen.tsx @@ -26,6 +26,7 @@ import { CurrencyInfo } from 'uniswap/src/features/dataApi/types' import { ElementName, SectionName } from 'uniswap/src/features/telemetry/constants' import Trace from 'uniswap/src/features/telemetry/Trace' import { + DecimalPadCalculatedSpaceId, DecimalPadCalculateSpace, DecimalPadInput, DecimalPadInputRef, @@ -66,7 +67,7 @@ import { formatCurrencyAmount } from 'utilities/src/format/localeBased' import { normalizePriceImpact } from 'utilities/src/format/normalizePriceImpact' import { truncateToMaxDecimals } from 'utilities/src/format/truncateToMaxDecimals' import { NumberType } from 'utilities/src/format/types' -import { isExtension, isInterface } from 'utilities/src/platform' +import { isExtension, isInterface, isMobileApp } from 'utilities/src/platform' import { useTrace } from 'utilities/src/telemetry/trace/TraceContext' const SWAP_DIRECTION_BUTTON_SIZE = { @@ -133,6 +134,7 @@ function SwapFormContent({ wrapCallback }: { wrapCallback?: WrapCallback }): JSX exactAmountTokenRef, exactCurrencyField, focusOnCurrencyField, + selectingCurrencyField, input, isFiatMode, output, @@ -528,7 +530,8 @@ function SwapFormContent({ wrapCallback }: { wrapCallback?: WrapCallback }): JSX [focusOnCurrencyField], ) - const showFooter = !hideFooter && exactAmountToken && input && output + // We *always* want to show the footer on native mobile because it's used to calculate the available space for the `DecimalPad`. + const showFooter = Boolean(!hideFooter && (isMobileApp || (exactAmountToken && input && output))) return ( @@ -551,7 +554,8 @@ function SwapFormContent({ wrapCallback }: { wrapCallback?: WrapCallback }): JSX currencyBalance={currencyBalances[CurrencyField.INPUT]} currencyField={CurrencyField.INPUT} currencyInfo={currencies[CurrencyField.INPUT]} - focus={focusOnCurrencyField === CurrencyField.INPUT} + // We do not want to force-focus the input when the token selector is open. + focus={selectingCurrencyField ? undefined : focusOnCurrencyField === CurrencyField.INPUT} isFiatMode={isFiatMode && exactFieldIsInput} isIndicativeLoading={trade.isIndicativeLoading} isLoading={!exactFieldIsInput && isSwapDataLoading} @@ -589,7 +593,8 @@ function SwapFormContent({ wrapCallback }: { wrapCallback?: WrapCallback }): JSX currencyField={CurrencyField.OUTPUT} currencyInfo={currencies[CurrencyField.OUTPUT]} disabled={exactOutputDisabled} - focus={focusOnCurrencyField === CurrencyField.OUTPUT} + // We do not want to force-focus the input when the token selector is open. + focus={selectingCurrencyField ? undefined : focusOnCurrencyField === CurrencyField.OUTPUT} isFiatMode={isFiatMode && exactFieldIsOutput} isLoading={!exactFieldIsOutput && isSwapDataLoading} resetSelection={resetSelection} @@ -630,6 +635,7 @@ function SwapFormContent({ wrapCallback }: { wrapCallback?: WrapCallback }): JSX )} + {/* attaches an absolutely positioned element that cannot be targeted without the below style */} @@ -646,6 +652,11 @@ function SwapFormContent({ wrapCallback }: { wrapCallback?: WrapCallback }): JSX )} + {/* + IMPORTANT: If you modify the footer layout, you must test this on a small device and verify that the `DecimalPad` is able to + properly calculate the correct height and it does not change its height when the gas and warning rows are shown/hidden, + or when moving from the review screen back to the form screen. + */} {showFooter && ( @@ -662,9 +673,11 @@ function SwapFormContent({ wrapCallback }: { wrapCallback?: WrapCallback }): JSX + {!isWeb && ( <> - + + - + + + + ) } diff --git a/packages/uniswap/src/features/transactions/swap/hooks/useFeeOnTransferAmount.ts b/packages/uniswap/src/features/transactions/swap/hooks/useFeeOnTransferAmount.ts index bbf8f2d922b..5e32d232d39 100644 --- a/packages/uniswap/src/features/transactions/swap/hooks/useFeeOnTransferAmount.ts +++ b/packages/uniswap/src/features/transactions/swap/hooks/useFeeOnTransferAmount.ts @@ -2,7 +2,7 @@ import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { CurrencyInfo } from 'uniswap/src/features/dataApi/types' import { useLocalizationContext } from 'uniswap/src/features/language/LocalizationContext' -import { FeeOnTransferFeeGroupProps } from 'uniswap/src/features/transactions/TransactionDetails/FeeOnTransferFee' +import { FeeOnTransferFeeGroupProps } from 'uniswap/src/features/transactions/TransactionDetails/types' import { getTradeAmounts } from 'uniswap/src/features/transactions/swap/hooks/getTradeAmounts' import { useUSDCValue } from 'uniswap/src/features/transactions/swap/hooks/useUSDCPrice' import { DerivedSwapInfo } from 'uniswap/src/features/transactions/swap/types/derivedSwapInfo' @@ -13,7 +13,7 @@ export function useFeeOnTransferAmounts( acceptedDerivedSwapInfo?: DerivedSwapInfo, ): FeeOnTransferFeeGroupProps | undefined { const { t } = useTranslation() - const { convertFiatAmountFormatted } = useLocalizationContext() + const { convertFiatAmountFormatted, formatCurrencyAmount } = useLocalizationContext() const { inputCurrencyAmount, outputCurrencyAmount } = getTradeAmounts(acceptedDerivedSwapInfo) const usdAmountIn = useUSDCValue(inputCurrencyAmount) @@ -24,6 +24,9 @@ export function useFeeOnTransferAmounts( return undefined } + const { currencies } = acceptedDerivedSwapInfo + const { input: inputCurrencyInfo, output: outputCurrencyInfo } = currencies + const acceptedTrade = acceptedDerivedSwapInfo.trade.trade ?? acceptedDerivedSwapInfo.trade.indicativeTrade const tradeHasFeeToken = acceptedTrade?.inputTax?.greaterThan(0) || acceptedTrade?.outputTax?.greaterThan(0) @@ -37,17 +40,35 @@ export function useFeeOnTransferAmounts( const formattedUsdTaxAmountIn = convertFiatAmountFormatted(usdTaxAmountIn, NumberType.FiatTokenQuantity) const formattedUsdTaxAmountOut = convertFiatAmountFormatted(usdTaxAmountOut, NumberType.FiatTokenQuantity) + const taxAmountIn = inputCurrencyAmount?.multiply(acceptedTrade.inputTax) + const taxAmountOut = outputCurrencyAmount?.multiply(acceptedTrade.outputTax) + const formattedAmountIn = formatCurrencyAmount({ value: taxAmountIn, type: NumberType.TokenTx }) + const formattedAmountOut = formatCurrencyAmount({ value: taxAmountOut, type: NumberType.TokenTx }) + return { inputTokenInfo: { + currencyInfo: inputCurrencyInfo, fee: acceptedTrade.inputTax, tokenSymbol: acceptedTrade.inputAmount.currency.symbol ?? t('token.symbol.input.fallback'), formattedUsdAmount: formattedUsdTaxAmountIn, + formattedAmount: formattedAmountIn, }, outputTokenInfo: { + currencyInfo: outputCurrencyInfo, fee: acceptedTrade.outputTax, tokenSymbol: acceptedTrade.outputAmount.currency.symbol ?? t('token.symbol.output.fallback'), formattedUsdAmount: formattedUsdTaxAmountOut, + formattedAmount: formattedAmountOut, }, } - }, [acceptedDerivedSwapInfo, usdAmountIn, usdAmountOut, convertFiatAmountFormatted, t]) + }, [ + acceptedDerivedSwapInfo, + usdAmountIn, + usdAmountOut, + convertFiatAmountFormatted, + formatCurrencyAmount, + inputCurrencyAmount, + outputCurrencyAmount, + t, + ]) } diff --git a/packages/uniswap/src/features/transactions/swap/hooks/useSwapNetworkNotification.test.ts b/packages/uniswap/src/features/transactions/swap/hooks/useSwapNetworkNotification.test.ts new file mode 100644 index 00000000000..c7d76b905ce --- /dev/null +++ b/packages/uniswap/src/features/transactions/swap/hooks/useSwapNetworkNotification.test.ts @@ -0,0 +1,90 @@ +import { act, renderHook } from '@testing-library/react-native' +import { useUniswapContext } from 'uniswap/src/contexts/UniswapContext' +import { useSwapNetworkNotification } from 'uniswap/src/features/transactions/swap/hooks/useSwapNetworkNotification' +import { UniverseChainId } from 'uniswap/src/types/chains' + +jest.mock('uniswap/src/contexts/UniswapContext', () => ({ + useUniswapContext: jest.fn(), +})) + +const onSwapChainsChangedMock = jest.fn() + +;(useUniswapContext as jest.Mock).mockReturnValue({ + onSwapChainsChanged: onSwapChainsChangedMock, +}) + +describe('useSwapNetworkNotification', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('does not show notification if input and output chain ids are the same', () => { + const { rerender } = renderHook( + ({ inputChainId, outputChainId }: { inputChainId?: UniverseChainId; outputChainId?: UniverseChainId }) => + useSwapNetworkNotification({ inputChainId, outputChainId }), + { + initialProps: { + inputChainId: UniverseChainId.Mainnet, + outputChainId: UniverseChainId.Mainnet, + }, + }, + ) + + act(() => { + rerender({ inputChainId: UniverseChainId.Mainnet, outputChainId: UniverseChainId.Mainnet }) + }) + + expect(onSwapChainsChangedMock).not.toHaveBeenCalled() + }) + it('shows bridge notification when input and output chain ids change', () => { + const { rerender } = renderHook( + ({ inputChainId, outputChainId }: { inputChainId?: UniverseChainId; outputChainId?: UniverseChainId }) => + useSwapNetworkNotification({ inputChainId, outputChainId }), + { + initialProps: { + inputChainId: UniverseChainId.Mainnet, + outputChainId: UniverseChainId.Optimism, + }, + }, + ) + + act(() => { + rerender({ inputChainId: UniverseChainId.Mainnet, outputChainId: UniverseChainId.Base }) + }) + + expect(onSwapChainsChangedMock).toHaveBeenCalledWith({ + chainId: UniverseChainId.Mainnet, + outputChainId: UniverseChainId.Base, + }) + }) + it('shows swap notification if input or output chain id changes', () => { + const { rerender } = renderHook( + ({ inputChainId, outputChainId }: { inputChainId?: UniverseChainId; outputChainId?: UniverseChainId }) => + useSwapNetworkNotification({ inputChainId, outputChainId }), + { + initialProps: { + inputChainId: UniverseChainId.Mainnet, + outputChainId: UniverseChainId.Mainnet, + } as { inputChainId?: UniverseChainId; outputChainId?: UniverseChainId }, + }, + ) + + act(() => { + rerender({ inputChainId: UniverseChainId.Optimism, outputChainId: undefined }) + }) + + expect(onSwapChainsChangedMock).toHaveBeenCalledWith({ + chainId: UniverseChainId.Optimism, + prevChainId: UniverseChainId.Mainnet, + }) + + act(() => { + rerender({ inputChainId: undefined, outputChainId: UniverseChainId.Base }) + }) + + expect(onSwapChainsChangedMock).toHaveBeenCalledWith({ + chainId: UniverseChainId.Base, + prevChainId: UniverseChainId.Optimism, + }) + }) +}) diff --git a/packages/uniswap/src/features/transactions/swap/hooks/useSwapTxAndGasInfo.ts b/packages/uniswap/src/features/transactions/swap/hooks/useSwapTxAndGasInfo.ts index 4e56fe5ae1a..247aeea9609 100644 --- a/packages/uniswap/src/features/transactions/swap/hooks/useSwapTxAndGasInfo.ts +++ b/packages/uniswap/src/features/transactions/swap/hooks/useSwapTxAndGasInfo.ts @@ -137,11 +137,13 @@ function getTotalGasFee( tokenApprovalInfo: TokenApprovalInfoWithGas, account?: AccountMeta, ): GasFeeResult { - const isConnected = account?.address - const isLoading = (isConnected && !tokenApprovalInfo) || swapGasResult.isLoading - const hasApprovalError = - isConnected && !tokenApprovalInfo?.isLoading && tokenApprovalInfo.action === ApprovalAction.Unknown - let error = swapGasResult.error ?? hasApprovalError ? new Error('Approval action unknown') : null + const isConnected = !!account?.address + const blockingUnknownApprovalStatus = isConnected && tokenApprovalInfo.action === ApprovalAction.Unknown + const isLoading = swapGasResult.isLoading || (blockingUnknownApprovalStatus && tokenApprovalInfo.isLoading) + + const approvalError = + blockingUnknownApprovalStatus && !tokenApprovalInfo.isLoading ? new Error('Approval action unknown') : null + let error = swapGasResult.error ?? approvalError // If swap requires revocation we expect simulation error so set error to null if (tokenApprovalInfo?.action === ApprovalAction.RevokeAndPermit2Approve) { @@ -166,7 +168,7 @@ function getTotalGasFee( // Do not populate gas fee: // - If errors exist on swap or approval requests. // - If we don't have both the approval and transaction gas fees. - if (approvalGasFeeMissing || swapGasFeeMissing || hasApprovalError || error) { + if (approvalGasFeeMissing || swapGasFeeMissing || blockingUnknownApprovalStatus || error) { return { value: undefined, error, isLoading } } diff --git a/packages/uniswap/src/features/transactions/swap/hooks/useTokenApprovalInfo.ts b/packages/uniswap/src/features/transactions/swap/hooks/useTokenApprovalInfo.ts index 9d5aa15c41d..368d495d967 100644 --- a/packages/uniswap/src/features/transactions/swap/hooks/useTokenApprovalInfo.ts +++ b/packages/uniswap/src/features/transactions/swap/hooks/useTokenApprovalInfo.ts @@ -6,6 +6,7 @@ import { AccountMeta } from 'uniswap/src/features/accounts/types' import { useActiveGasStrategy, useShadowGasStrategies } from 'uniswap/src/features/gas/hooks' import { areEqualGasStrategies } from 'uniswap/src/features/gas/types' import { ApprovalAction, TokenApprovalInfo } from 'uniswap/src/features/transactions/swap/types/trade' +import { isUniswapX } from 'uniswap/src/features/transactions/swap/utils/routing' import { getTokenAddressForApi, toTradingApiSupportedChainId, @@ -39,8 +40,9 @@ export function useTokenApprovalInfo(params: TokenApprovalInfoParams): TokenAppr const isWrap = wrapType !== WrapType.NotApplicable const address = account?.address + const inputWillBeWrapped = routing && isUniswapX({ routing }) // Off-chain orders must have wrapped currencies approved, rather than natives. - const currencyIn = routing === Routing.DUTCH_V2 ? currencyInAmount?.currency.wrapped : currencyInAmount?.currency + const currencyIn = inputWillBeWrapped ? currencyInAmount?.currency.wrapped : currencyInAmount?.currency const amount = currencyInAmount?.quotient.toString() const tokenInAddress = getTokenAddressForApi(currencyIn) diff --git a/packages/uniswap/src/features/transactions/swap/modals/FeeOnTransferWarning.tsx b/packages/uniswap/src/features/transactions/swap/modals/FeeOnTransferWarning.tsx index 2b7d8527947..19b12e0c030 100644 --- a/packages/uniswap/src/features/transactions/swap/modals/FeeOnTransferWarning.tsx +++ b/packages/uniswap/src/features/transactions/swap/modals/FeeOnTransferWarning.tsx @@ -1,43 +1,84 @@ -import { PropsWithChildren } from 'react' +import { PropsWithChildren, useState } from 'react' import { useTranslation } from 'react-i18next' -import { isWeb } from 'ui/src' -import { WarningInfo } from 'uniswap/src/components/modals/WarningModal/WarningInfo' -import { LearnMoreLink } from 'uniswap/src/components/text/LearnMoreLink' -import WarningIcon from 'uniswap/src/components/warnings/WarningIcon' -import { uniswapUrls } from 'uniswap/src/constants/urls' -import { ModalName } from 'uniswap/src/features/telemetry/constants' -import { TokenFeeInfo, getFeeSeverity } from 'uniswap/src/features/transactions/TransactionDetails/FeeOnTransferFee' +import { Flex, TouchableArea } from 'ui/src' +import { InfoCircle } from 'ui/src/components/icons/InfoCircle' +import { InfoTooltip } from 'uniswap/src/components/tooltip/InfoTooltip' +import { useLocalizationContext } from 'uniswap/src/features/language/LocalizationContext' +import TokenWarningModal, { FeeRow, WarningModalInfoContainer } from 'uniswap/src/features/tokens/TokenWarningModal' +import { getModalHeaderText, getModalSubtitleTokenWarningText } from 'uniswap/src/features/tokens/safetyUtils' +import { FoTFeeType, TokenFeeInfo } from 'uniswap/src/features/transactions/TransactionDetails/types' +import { getFeeSeverity } from 'uniswap/src/features/transactions/TransactionDetails/utils' +import { isInterface } from 'utilities/src/platform' -export function FeeOnTransferWarning({ children, feeInfo }: PropsWithChildren<{ feeInfo: TokenFeeInfo }>): JSX.Element { +export function FeeOnTransferWarning({ + children, + feeInfo, + feeType, +}: PropsWithChildren<{ feeInfo: TokenFeeInfo; feeType: FoTFeeType }>): JSX.Element { const { t } = useTranslation() - const { severity } = getFeeSeverity(feeInfo.fee) - const caption = t('swap.warning.feeOnTransfer.message') - const title = t('swap.warning.feeOnTransfer.title') + const { formatPercent } = useLocalizationContext() + const [showModal, setShowModal] = useState(false) + + const { fee, tokenSymbol } = feeInfo + const feePercent = parseFloat(fee.toFixed()) + const formattedFeePercent = formatPercent(feePercent) + + const { tokenProtectionWarning } = getFeeSeverity(feeInfo.fee) + // These should never be null bc tokenProtectionWarning is never None + const title = getModalHeaderText({ t, tokenProtectionWarning, tokenSymbol0: tokenSymbol }) ?? '' + const subtitle = + getModalSubtitleTokenWarningText({ t, tokenProtectionWarning, tokenSymbol, formattedFeePercent }) ?? '' + + if (isInterface) { + return ( + + + + } + trigger={} + triggerPlacement="end" + > + {children} + + ) + } + + const onPress = (): void => { + setShowModal(true) + } + + const onClose = (): void => { + setShowModal(false) + } return ( - + + + {children} + + + + {feeInfo.currencyInfo && ( + - } - modalProps={{ - caption, - rejectText: t('common.button.close'), - icon: , - modalName: ModalName.FOTInfo, - title, - rejectButtonTheme: 'tertiary', - backgroundIconColor: false, - }} - tooltipProps={{ - text: caption, - title, - placement: 'top', - }} - > - {children} - + )} + ) } diff --git a/packages/uniswap/src/features/transactions/swap/modals/NetworkFeeWarning.tsx b/packages/uniswap/src/features/transactions/swap/modals/NetworkFeeWarning.tsx index 9b61f72e73a..c8979c03afc 100644 --- a/packages/uniswap/src/features/transactions/swap/modals/NetworkFeeWarning.tsx +++ b/packages/uniswap/src/features/transactions/swap/modals/NetworkFeeWarning.tsx @@ -5,9 +5,9 @@ import { AlertTriangleFilled } from 'ui/src/components/icons/AlertTriangleFilled import { Gas } from 'ui/src/components/icons/Gas' import { UniswapXFee } from 'uniswap/src/components/gas/NetworkFee' import { WarningInfo } from 'uniswap/src/components/modals/WarningModal/WarningInfo' -import { WarningTooltipProps } from 'uniswap/src/components/modals/WarningModal/WarningTooltipProps' import { WarningSeverity } from 'uniswap/src/components/modals/WarningModal/types' import { LearnMoreLink } from 'uniswap/src/components/text/LearnMoreLink' +import { InfoTooltipProps } from 'uniswap/src/components/tooltip/InfoTooltipProps' import { uniswapUrls } from 'uniswap/src/constants/urls' import { FormattedUniswapXGasFeeInfo } from 'uniswap/src/features/gas/types' import { ModalName } from 'uniswap/src/features/telemetry/constants' @@ -21,8 +21,8 @@ export function NetworkFeeWarning({ uniswapXGasFeeInfo, }: PropsWithChildren<{ gasFeeHighRelativeToValue?: boolean - tooltipTrigger?: WarningTooltipProps['trigger'] - placement?: WarningTooltipProps['placement'] + tooltipTrigger?: InfoTooltipProps['trigger'] + placement?: InfoTooltipProps['placement'] uniswapXGasFeeInfo?: FormattedUniswapXGasFeeInfo }>): JSX.Element { const colors = useSporeColors() diff --git a/packages/uniswap/src/features/transactions/swap/modals/UniswapXInfo.tsx b/packages/uniswap/src/features/transactions/swap/modals/UniswapXInfo.tsx index fe295c8be2e..25912c46954 100644 --- a/packages/uniswap/src/features/transactions/swap/modals/UniswapXInfo.tsx +++ b/packages/uniswap/src/features/transactions/swap/modals/UniswapXInfo.tsx @@ -4,9 +4,9 @@ import { UniswapXText, isWeb } from 'ui/src' import { UniswapX } from 'ui/src/components/icons/UniswapX' import { colors, opacify } from 'ui/src/theme' import { WarningInfo } from 'uniswap/src/components/modals/WarningModal/WarningInfo' -import { WarningTooltipProps } from 'uniswap/src/components/modals/WarningModal/WarningTooltipProps' import { WarningSeverity } from 'uniswap/src/components/modals/WarningModal/types' import { LearnMoreLink } from 'uniswap/src/components/text/LearnMoreLink' +import { InfoTooltipProps } from 'uniswap/src/components/tooltip/InfoTooltipProps' import { uniswapUrls } from 'uniswap/src/constants/urls' import { ModalName } from 'uniswap/src/features/telemetry/constants' @@ -15,8 +15,8 @@ export function UniswapXInfo({ tooltipTrigger, placement = 'top', }: PropsWithChildren<{ - tooltipTrigger?: WarningTooltipProps['trigger'] - placement?: WarningTooltipProps['placement'] + tooltipTrigger?: InfoTooltipProps['trigger'] + placement?: InfoTooltipProps['placement'] }>): JSX.Element { const { t } = useTranslation() diff --git a/packages/uniswap/src/features/transactions/swap/review/SwapDetails.tsx b/packages/uniswap/src/features/transactions/swap/review/SwapDetails.tsx index 0fc21cea49d..8e8ffabfe75 100644 --- a/packages/uniswap/src/features/transactions/swap/review/SwapDetails.tsx +++ b/packages/uniswap/src/features/transactions/swap/review/SwapDetails.tsx @@ -7,8 +7,11 @@ import { GasFeeResult } from 'uniswap/src/features/gas/types' import { useLocalizationContext } from 'uniswap/src/features/language/LocalizationContext' import Trace from 'uniswap/src/features/telemetry/Trace' import { ElementName } from 'uniswap/src/features/telemetry/constants' -import { FeeOnTransferFeeGroupProps } from 'uniswap/src/features/transactions/TransactionDetails/FeeOnTransferFee' import { TransactionDetails } from 'uniswap/src/features/transactions/TransactionDetails/TransactionDetails' +import { + FeeOnTransferFeeGroupProps, + TokenWarningProps, +} from 'uniswap/src/features/transactions/TransactionDetails/types' import { AcrossRoutingInfo } from 'uniswap/src/features/transactions/swap/modals/AcrossRoutingInfo' import { EstimatedTime } from 'uniswap/src/features/transactions/swap/review/EstimatedTime' import { MaxSlippageRow } from 'uniswap/src/features/transactions/swap/review/MaxSlippageRow' @@ -27,7 +30,8 @@ interface SwapDetailsProps { customSlippageTolerance?: number derivedSwapInfo: DerivedSwapInfo feeOnTransferProps?: FeeOnTransferFeeGroupProps - feeOnTransferWarningChecked?: boolean + tokenWarningProps: TokenWarningProps + tokenWarningChecked?: boolean gasFallbackUsed?: boolean gasFee: GasFeeResult uniswapXGasBreakdown?: UniswapXGasBreakdown @@ -35,7 +39,7 @@ interface SwapDetailsProps { warning?: Warning onAcceptTrade: () => void onShowWarning?: () => void - setFeeOnTransferWarningChecked?: (checked: boolean) => void + setTokenWarningChecked?: (checked: boolean) => void } export function SwapDetails({ @@ -44,14 +48,15 @@ export function SwapDetails({ customSlippageTolerance, derivedSwapInfo, feeOnTransferProps, - feeOnTransferWarningChecked, + tokenWarningProps, + tokenWarningChecked, gasFee, uniswapXGasBreakdown, newTradeRequiresAcceptance, warning, onAcceptTrade, onShowWarning, - setFeeOnTransferWarningChecked, + setTokenWarningChecked, }: SwapDetailsProps): JSX.Element { const { t } = useTranslation() @@ -95,8 +100,9 @@ export function SwapDetails({ } chainId={acceptedTrade.inputAmount.currency.chainId} feeOnTransferProps={feeOnTransferProps} - feeOnTransferWarningChecked={feeOnTransferWarningChecked} - setFeeOnTransferWarningChecked={setFeeOnTransferWarningChecked} + tokenWarningProps={tokenWarningProps} + tokenWarningChecked={tokenWarningChecked} + setTokenWarningChecked={setTokenWarningChecked} gasFee={gasFee} swapFee={acceptedTrade.swapFee} swapFeeUsd={swapFeeUsd} diff --git a/packages/uniswap/src/features/transactions/swap/review/SwapErrorScreen.tsx b/packages/uniswap/src/features/transactions/swap/review/SwapErrorScreen.tsx index 036d6d54c59..6b97058ae39 100644 --- a/packages/uniswap/src/features/transactions/swap/review/SwapErrorScreen.tsx +++ b/packages/uniswap/src/features/transactions/swap/review/SwapErrorScreen.tsx @@ -1,14 +1,18 @@ import { useTranslation } from 'react-i18next' -import { Button, Flex, isWeb } from 'ui/src' +import { Button, Flex, Text, TouchableArea, isWeb } from 'ui/src' +import { HelpCenter } from 'ui/src/components/icons/HelpCenter' import { X } from 'ui/src/components/icons/X' import { WarningModalContent } from 'uniswap/src/components/modals/WarningModal/WarningModal' import { WarningSeverity } from 'uniswap/src/components/modals/WarningModal/types' +import { uniswapUrls } from 'uniswap/src/constants/urls' import { ProtocolItems } from 'uniswap/src/data/tradingApi/__generated__' +import { ModalName } from 'uniswap/src/features/telemetry/constants' import { TransactionModalInnerContainer } from 'uniswap/src/features/transactions/TransactionModal/TransactionModal' import { useTransactionModalContext } from 'uniswap/src/features/transactions/TransactionModal/TransactionModalContext' import { TransactionStepFailedError, getErrorContent } from 'uniswap/src/features/transactions/errors' import { useSwapFormContext } from 'uniswap/src/features/transactions/swap/contexts/SwapFormContext' import { TransactionStepType } from 'uniswap/src/features/transactions/swap/types/steps' +import { openUri } from 'uniswap/src/utils/linking' export function SwapErrorScreen({ submissionError, @@ -25,8 +29,7 @@ export function SwapErrorScreen({ const { bottomSheetViewStyles } = useTransactionModalContext() const { updateSwapForm, selectedProtocols } = useSwapFormContext() - // TODO(WEB-4970): use getErrorContent().supportArticleURL to render help center UI - const { title, message } = getErrorContent(t, submissionError) + const { title, message, supportArticleURL } = getErrorContent(t, submissionError) const isUniswapXBackendError = submissionError instanceof TransactionStepFailedError && @@ -37,20 +40,37 @@ export function SwapErrorScreen({ if (isUniswapXBackendError) { // Update swap preferences for this session to exclude UniswapX if Uniswap x failed const updatedProtocols = selectedProtocols.filter((protocol) => protocol !== ProtocolItems.UNISWAPX_V2) - updateSwapForm({ - selectedProtocols: updatedProtocols, - }) + updateSwapForm({ selectedProtocols: updatedProtocols }) } else { resubmitSwap() } setSubmissionError(undefined) } + const onPressGetHelp = async (): Promise => { + await openUri(supportArticleURL ?? uniswapUrls.helpUrl) + } + return ( - - {isWeb && ( + {isWeb && ( + + + + {' '} + + {t('common.getHelp.button')} + + + + {isMobileApp && ( + + )} diff --git a/packages/wallet/src/features/unitags/ClaimUnitagContent.tsx b/packages/wallet/src/features/unitags/ClaimUnitagContent.tsx index 9f9cb588be9..3904ddf0c3e 100644 --- a/packages/wallet/src/features/unitags/ClaimUnitagContent.tsx +++ b/packages/wallet/src/features/unitags/ClaimUnitagContent.tsx @@ -1,10 +1,10 @@ import { EventConsumer, EventMapBase } from '@react-navigation/core' import { ADDRESS_ZERO } from '@uniswap/v3-sdk' -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { ActivityIndicator, LayoutChangeEvent } from 'react-native' import { useAnimatedStyle, useSharedValue, withDelay, withTiming } from 'react-native-reanimated' -import { AnimatePresence, Button, Flex, FlexProps, Text, TouchableArea, useSporeColors } from 'ui/src' +import { AnimatePresence, Button, Flex, FlexProps, Input, Text, TouchableArea, useSporeColors } from 'ui/src' import { InfoCircleFilled } from 'ui/src/components/icons' import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' import { useDynamicFontSizing } from 'ui/src/hooks/useDynamicFontSizing' @@ -22,7 +22,7 @@ import { import { shortenAddress } from 'uniswap/src/utils/addresses' import { dismissNativeKeyboard } from 'utilities/src/device/keyboard' import { logger } from 'utilities/src/logger/logger' -import { isExtension } from 'utilities/src/platform' +import { isExtension, isMobileApp } from 'utilities/src/platform' import { ONE_SECOND_MS } from 'utilities/src/time/time' import { UnitagInfoModal } from 'wallet/src/features/unitags/UnitagInfoModal' import { UnitagName } from 'wallet/src/features/unitags/UnitagName' @@ -35,6 +35,7 @@ const MAX_UNITAG_CHAR_LENGTH = 20 const MAX_INPUT_FONT_SIZE = 36 const MIN_INPUT_FONT_SIZE = 22 const MAX_CHAR_PIXEL_WIDTH = 20 +const SLIDE_IN_AMOUNT = isExtension ? 0 : 40 // Used in dynamic font size width calculation to ignore `.` characters const UNITAG_SUFFIX_CHARS_ONLY = UNITAG_SUFFIX.replaceAll('.', '') @@ -48,7 +49,7 @@ export type ClaimUnitagContentProps = { animateY?: boolean navigationEventConsumer?: EventConsumer onNavigateContinue?: (params: SharedUnitagScreenParams[UnitagScreens.ChooseProfilePicture]) => void - onComplete?: () => void + onComplete?: (unitag: string) => void } export function ClaimUnitagContent({ @@ -61,6 +62,7 @@ export function ClaimUnitagContent({ }: ClaimUnitagContentProps): JSX.Element { const { t } = useTranslation() const colors = useSporeColors() + const textInputRef = useRef(null) const inputPlaceholder = getYourNameString(t('unitags.claim.username.default')) @@ -89,6 +91,14 @@ export function ClaimUnitagContent({ MIN_INPUT_FONT_SIZE, ) + const focusUniTagTextInput = (): void | null => textInputRef.current && textInputRef.current.focus() + + useEffect(() => { + return navigationEventConsumer?.addListener('transitionEnd', () => { + focusUniTagTextInput() + }) + }, [navigationEventConsumer]) + useEffect(() => { const unsubscribe = navigationEventConsumer?.addListener('focus', () => { // Reset the Unitag to check @@ -108,6 +118,7 @@ export function ClaimUnitagContent({ setTimeout(() => { setShowTextInputView(true) addressViewOpacity.value = withTiming(1, { duration: ONE_SECOND_MS / 2 }) + focusUniTagTextInput() }, ONE_SECOND_MS) }) @@ -134,7 +145,7 @@ export function ClaimUnitagContent({ onSetFontSize(text + UNITAG_SUFFIX_CHARS_ONLY) } - setUnitagInputValue(text?.trim()) + setUnitagInputValue(text?.trim().toLocaleLowerCase()) }, [inputPlaceholder, onSetFontSize], ) @@ -176,7 +187,7 @@ export function ClaimUnitagContent({ ) // Navigate to ChooseProfilePicture screen after initial delay + translation to allow animations to finish setTimeout(() => { - onComplete?.() + onComplete?.(unitag) if (unitagAddress && onNavigateContinue) { onNavigateContinue({ unitag, entryPoint, address: unitagAddress, unitagFontSize: fontSize }) } @@ -244,7 +255,7 @@ export function ClaimUnitagContent({ <> { onLayout(event) @@ -269,17 +280,19 @@ export function ClaimUnitagContent({ key="input-container" row animation="quick" - enterStyle={{ opacity: 0, x: 40 }} - exitStyle={{ opacity: 0, x: 40 }} + enterStyle={{ opacity: 0, x: SLIDE_IN_AMOUNT }} + exitStyle={{ opacity: 0, x: SLIDE_IN_AMOUNT }} gap="$none" {...extensionStyling} > - +