ci(release): publish latest release

parent e6ae20f1
......@@ -5,7 +5,6 @@ ignores: [
'@uniswap/eslint-config',
'i18next',
'moti',
'wrangler',
# Dependencies that depcheck thinks are missing but are actually present or never used
'@yarnpkg/core',
'@yarnpkg/cli',
......
......@@ -60,9 +60,3 @@ apps/mobile/.maestro/scripts/testIds.js
# RNEF
.rnef/
# claude
claude.md
claude.local.md
CLAUDE.md
CLAUDE.local.md
......@@ -11,11 +11,11 @@ __mocks__
*.html
*.inc
*.json
*.jsonc
*.md
*.yml
build
craco.config.cjs
cypress
dist
jest-setup.js
jest.config.js
......
* @uniswap/web-admins
IPFS hash of the deployment:
- CIDv0: `QmVzsLbRpsi2d5BKNShX4XHtieNHiwLTMAnf78ehMM1YZ8`
- CIDv1: `bafybeidrzqulidzlilytjjw4hihdvu2iyod7xvuy2f35koh6vebadxnf4m`
The latest release is always mirrored at [app.uniswap.org](https://app.uniswap.org).
You can also access the Uniswap Interface from an IPFS gateway.
**BEWARE**: The Uniswap interface uses [`localStorage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) to remember your settings, such as which tokens you have imported.
**You should always use an IPFS gateway that enforces origin separation**, or our hosted deployment of the latest release at [app.uniswap.org](https://app.uniswap.org).
Your Uniswap settings are never remembered across different URLs.
IPFS gateways:
- https://bafybeidrzqulidzlilytjjw4hihdvu2iyod7xvuy2f35koh6vebadxnf4m.ipfs.dweb.link/
- [ipfs://QmVzsLbRpsi2d5BKNShX4XHtieNHiwLTMAnf78ehMM1YZ8/](ipfs://QmVzsLbRpsi2d5BKNShX4XHtieNHiwLTMAnf78ehMM1YZ8/)
### 5.100.1 (2025-06-25)
### Bug Fixes
* **web:** use SERVICE_ACCOUNT_PAT instead of GITHUB_TOKEN (#21251) b92a468
We are back with some new updates! Here’s the latest:
- Performance Improvements
- Various bug fixes and performance improvements
\ No newline at end of file
web/5.100.1
\ No newline at end of file
extension/1.23.0
\ No newline at end of file
......@@ -2,10 +2,6 @@
## Developer Quickstart
### Environment variables
Before running the extension, you need to get the environment variables from 1password in order to get full functionality. Run the command `yarn extension env:local:download` to copy them to your root folder.
### Running the extension locally
To run the extension, run the following from the top level of the monorepo:
......@@ -15,7 +11,11 @@ yarn
yarn extension start
```
Then, load the extension into Chrome:
### Environment variables
You need to get the environment variables from 1password in order to get full functionality. Run the command `yarn extension env:local:download` to copy them to your root folder.
### Loading the extension into Chrome
1. Go to **chrome://extensions**
2. At the top right, turn on **Developer mode**
......
......@@ -38,7 +38,7 @@
"react-native-web": "0.19.13",
"react-qr-code": "2.0.12",
"react-redux": "8.0.5",
"react-router-dom": "6.30.1",
"react-router-dom": "6.10.0",
"redux": "4.2.1",
"redux-logger": "3.0.6",
"redux-persist": "6.0.0",
......
......@@ -35,7 +35,6 @@ import { OTPInput } from 'src/app/features/onboarding/scan/OTPInput'
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 { ROUTER_FUTURE_FLAGS, ROUTER_PROVIDER_FUTURE_FLAGS } from 'src/app/navigation/routerConfig'
import { setRouter, setRouterState } from 'src/app/navigation/state'
import { initExtensionAnalytics } from 'src/app/utils/analytics'
import { checksIfSupportsSidePanel } from 'src/app/utils/chrome'
......@@ -140,19 +139,14 @@ const allRoutes = [
},
]
const router = createHashRouter(
[
{
path: `/${TopLevelRoutes.Onboarding}`,
element: <OnboardingWrapper />,
errorElement: <ErrorElement />,
children: !supportsSidePanel ? [unsupportedRoute] : allRoutes,
},
],
const router = createHashRouter([
{
future: ROUTER_FUTURE_FLAGS,
path: `/${TopLevelRoutes.Onboarding}`,
element: <OnboardingWrapper />,
errorElement: <ErrorElement />,
children: !supportsSidePanel ? [unsupportedRoute] : allRoutes,
},
)
])
function ScantasticFlow({ isResetting = false }: { isResetting?: boolean }): JSX.Element {
return (
......@@ -199,7 +193,7 @@ export default function OnboardingApp(): JSX.Element {
<PersistGate persistor={getReduxPersistor()}>
<BaseAppContainer appName={DatadogAppNameTag.Onboarding}>
<PrimaryAppInstanceDebuggerLazy />
<RouterProvider router={router} future={ROUTER_PROVIDER_FUTURE_FLAGS} />
<RouterProvider router={router} />
</BaseAppContainer>
</PersistGate>
)
......
......@@ -7,7 +7,6 @@ import { RouterProvider, createHashRouter } from 'react-router-dom'
import { ErrorElement } from 'src/app/components/ErrorElement'
import { BaseAppContainer } from 'src/app/core/BaseAppContainer'
import { DatadogAppNameTag } from 'src/app/datadog'
import { ROUTER_FUTURE_FLAGS, ROUTER_PROVIDER_FUTURE_FLAGS } from 'src/app/navigation/routerConfig'
import { initExtensionAnalytics } from 'src/app/utils/analytics'
import { Button, Flex, Image, Text } from 'ui/src'
import { UNISWAP_LOGO } from 'ui/src/assets'
......@@ -18,18 +17,13 @@ import { ElementName } from 'uniswap/src/features/telemetry/constants'
import { ExtensionScreens } from 'uniswap/src/types/screens/extension'
import { useTestnetModeForLoggingAndAnalytics } from 'wallet/src/features/testnetMode/hooks/useTestnetModeForLoggingAndAnalytics'
const router = createHashRouter(
[
{
path: '',
element: <PopupContent />,
errorElement: <ErrorElement />,
},
],
const router = createHashRouter([
{
future: ROUTER_FUTURE_FLAGS,
path: '',
element: <PopupContent />,
errorElement: <ErrorElement />,
},
)
])
function PopupContent(): JSX.Element {
const { t } = useTranslation()
......@@ -108,7 +102,7 @@ export default function PopupApp(): JSX.Element {
return (
<BaseAppContainer appName={DatadogAppNameTag.Popup}>
<RouterProvider router={router} future={ROUTER_PROVIDER_FUTURE_FLAGS} />
<RouterProvider router={router} />
</BaseAppContainer>
)
}
......@@ -29,7 +29,6 @@ import { SwapFlowScreen } from 'src/app/features/swap/SwapFlowScreen'
import { useIsWalletUnlocked } from 'src/app/hooks/useIsWalletUnlocked'
import { AppRoutes, RemoveRecoveryPhraseRoutes, SettingsRoutes } from 'src/app/navigation/constants'
import { MainContent, WebNavigation } from 'src/app/navigation/navigation'
import { ROUTER_FUTURE_FLAGS, ROUTER_PROVIDER_FUTURE_FLAGS } from 'src/app/navigation/routerConfig'
import { setRouter, setRouterState } from 'src/app/navigation/state'
import { initExtensionAnalytics } from 'src/app/utils/analytics'
import {
......@@ -49,93 +48,88 @@ import { useInterval } from 'utilities/src/time/timing'
import { useTestnetModeForLoggingAndAnalytics } from 'wallet/src/features/testnetMode/hooks/useTestnetModeForLoggingAndAnalytics'
import { getReduxPersistor } from 'wallet/src/state/persistor'
const router = createHashRouter(
[
{
path: '',
element: <SidebarWrapper />,
errorElement: <ErrorElement />,
children: [
{
path: '',
element: <MainContent />,
},
{
path: AppRoutes.AccountSwitcher,
element: <AccountSwitcherScreen />,
},
{
path: AppRoutes.Settings,
element: <SettingsScreenWrapper />,
children: [
{
path: '',
element: <SettingsScreen />,
},
{
path: SettingsRoutes.ChangePassword,
element: <SettingsChangePasswordScreen />,
},
{
path: SettingsRoutes.DeviceAccess,
element: <DeviceAccessScreen />,
},
isDevEnv()
? {
path: SettingsRoutes.DevMenu,
element: <DevMenuScreen />,
}
: {},
{
path: SettingsRoutes.ViewRecoveryPhrase,
element: <ViewRecoveryPhraseScreen />,
},
{
path: SettingsRoutes.BackupRecoveryPhrase,
element: <BackupRecoveryPhraseScreen />,
},
{
path: SettingsRoutes.RemoveRecoveryPhrase,
children: [
{
path: RemoveRecoveryPhraseRoutes.Wallets,
element: <RemoveRecoveryPhraseWallets />,
},
{
path: RemoveRecoveryPhraseRoutes.Verify,
element: <RemoveRecoveryPhraseVerify />,
},
],
},
{
path: SettingsRoutes.ManageConnections,
element: <SettingsManageConnectionsScreen />,
},
{
path: SettingsRoutes.SmartWallet,
element: <SmartWalletSettingsScreen />,
},
],
},
{
path: AppRoutes.Send,
element: <SendFlow />,
},
{
path: AppRoutes.Swap,
element: <SwapFlowScreen />,
},
{
path: AppRoutes.Receive,
element: <ReceiveScreen />,
},
],
},
],
const router = createHashRouter([
{
future: ROUTER_FUTURE_FLAGS,
path: '',
element: <SidebarWrapper />,
errorElement: <ErrorElement />,
children: [
{
path: '',
element: <MainContent />,
},
{
path: AppRoutes.AccountSwitcher,
element: <AccountSwitcherScreen />,
},
{
path: AppRoutes.Settings,
element: <SettingsScreenWrapper />,
children: [
{
path: '',
element: <SettingsScreen />,
},
{
path: SettingsRoutes.ChangePassword,
element: <SettingsChangePasswordScreen />,
},
{
path: SettingsRoutes.DeviceAccess,
element: <DeviceAccessScreen />,
},
isDevEnv()
? {
path: SettingsRoutes.DevMenu,
element: <DevMenuScreen />,
}
: {},
{
path: SettingsRoutes.ViewRecoveryPhrase,
element: <ViewRecoveryPhraseScreen />,
},
{
path: SettingsRoutes.BackupRecoveryPhrase,
element: <BackupRecoveryPhraseScreen />,
},
{
path: SettingsRoutes.RemoveRecoveryPhrase,
children: [
{
path: RemoveRecoveryPhraseRoutes.Wallets,
element: <RemoveRecoveryPhraseWallets />,
},
{
path: RemoveRecoveryPhraseRoutes.Verify,
element: <RemoveRecoveryPhraseVerify />,
},
],
},
{
path: SettingsRoutes.ManageConnections,
element: <SettingsManageConnectionsScreen />,
},
{
path: SettingsRoutes.SmartWallet,
element: <SmartWalletSettingsScreen />,
},
],
},
{
path: AppRoutes.Send,
element: <SendFlow />,
},
{
path: AppRoutes.Swap,
element: <SwapFlowScreen />,
},
{
path: AppRoutes.Receive,
element: <ReceiveScreen />,
},
],
},
)
])
const PORT_PING_INTERVAL = 5 * ONE_SECOND_MS
function useDappRequestPortListener(): void {
......@@ -252,7 +246,7 @@ export default function SidebarApp(): JSX.Element {
<BaseAppContainer appName={DatadogAppNameTag.Sidebar}>
<DappContextProvider>
<PrimaryAppInstanceDebuggerLazy />
<RouterProvider router={router} future={ROUTER_PROVIDER_FUTURE_FLAGS} />
<RouterProvider router={router} />
</DappContextProvider>
</BaseAppContainer>
</PersistGate>
......
......@@ -19,7 +19,6 @@ import { UnitagConfirmationScreen } from 'src/app/features/unitags/UnitagConfirm
import { UnitagCreateUsernameScreen } from 'src/app/features/unitags/UnitagCreateUsernameScreen'
import { UnitagIntroScreen } from 'src/app/features/unitags/UnitagIntroScreen'
import { UnitagClaimRoutes } from 'src/app/navigation/constants'
import { ROUTER_FUTURE_FLAGS, ROUTER_PROVIDER_FUTURE_FLAGS } from 'src/app/navigation/routerConfig'
import { setRouter, setRouterState } from 'src/app/navigation/state'
import { initExtensionAnalytics } from 'src/app/utils/analytics'
import { Flex } from 'ui/src'
......@@ -28,36 +27,31 @@ import { usePrevious } from 'utilities/src/react/hooks'
import { useTestnetModeForLoggingAndAnalytics } from 'wallet/src/features/testnetMode/hooks/useTestnetModeForLoggingAndAnalytics'
import { useAccountAddressFromUrlWithThrow } from 'wallet/src/features/wallet/hooks'
const router = createHashRouter(
[
{
path: '',
element: <UnitagAppInner />,
children: [
{
path: UnitagClaimRoutes.ClaimIntro,
element: <UnitagClaimFlow />,
errorElement: <ErrorElement />,
},
{
path: UnitagClaimRoutes.EditProfile,
element: <UnitagEditProfileFlow />,
errorElement: <ErrorElement />,
},
],
},
],
const router = createHashRouter([
{
future: ROUTER_FUTURE_FLAGS,
path: '',
element: <UnitagAppInner />,
children: [
{
path: UnitagClaimRoutes.ClaimIntro,
element: <UnitagClaimFlow />,
errorElement: <ErrorElement />,
},
{
path: UnitagClaimRoutes.EditProfile,
element: <UnitagEditProfileFlow />,
errorElement: <ErrorElement />,
},
],
},
)
])
/**
* Note: we are using a pattern here to avoid circular dependencies, because
* this is the root of the app and it imports all sub-pages, we need to push the
* router/router state to a different file so it can be imported by those pages
*/
router.subscribe((state: any) => {
router.subscribe((state) => {
setRouterState(state)
})
......@@ -81,7 +75,7 @@ function UnitagAppInner(): JSX.Element {
// needed to reload on address param change for hash router
router
.navigate(0)
.catch((e: any) => logger.error(e, { tags: { file: 'UnitagClaimApp.tsx', function: 'UnitagClaimAppInner' } }))
.catch((e) => logger.error(e, { tags: { file: 'UnitagClaimApp.tsx', function: 'UnitagClaimAppInner' } }))
}
}, [address, prevAddress])
......@@ -144,7 +138,7 @@ export default function UnitagClaimApp(): JSX.Element {
return (
<BaseAppContainer appName={DatadogAppNameTag.UnitagClaim}>
<RouterProvider router={router} future={ROUTER_PROVIDER_FUTURE_FLAGS} />
<RouterProvider router={router} />
</BaseAppContainer>
)
}
......@@ -115,7 +115,7 @@ export function AccountItem({ address, onAccountSelect, balanceUSD }: AccountIte
e.preventDefault()
e.stopPropagation()
navigateTo(`/${AppRoutes.Settings}/${SettingsRoutes.ManageConnections}`)
navigateTo(`${AppRoutes.Settings}/${SettingsRoutes.ManageConnections}`)
},
Icon: Globe,
},
......
......@@ -178,7 +178,7 @@ export function AccountSwitcherScreen(): JSX.Element {
{
label: t('account.wallet.menu.manageConnections'),
onPress: () => navigateTo(`/${AppRoutes.Settings}/${SettingsRoutes.ManageConnections}`),
onPress: () => navigateTo(`${AppRoutes.Settings}/${SettingsRoutes.ManageConnections}`),
Icon: Globe,
},
{
......
......@@ -17,7 +17,7 @@ export function HomeIntroCardStack(): JSX.Element | null {
}, [activeAccount.address])
const navigateToBackupFlow = useCallback((): void => {
navigateTo(`/${AppRoutes.Settings}/${SettingsRoutes.BackupRecoveryPhrase}`)
navigateTo(`${AppRoutes.Settings}/${SettingsRoutes.BackupRecoveryPhrase}`)
}, [navigateTo])
const { cards } = useSharedIntroCards({
......
......@@ -21,6 +21,7 @@ import WalletPreviewCard from 'wallet/src/components/WalletPreviewCard/WalletPre
import { useOnboardingContext } from 'wallet/src/features/onboarding/OnboardingContext'
import { useImportableAccounts } from 'wallet/src/features/onboarding/hooks/useImportableAccounts'
import { useSelectAccounts } from 'wallet/src/features/onboarding/hooks/useSelectAccounts'
import { useAnyAccountEligibleForDelegation } from 'wallet/src/features/smartWallet/hooks/useAnyAccountEligibleForDelegation'
import { BackupType } from 'wallet/src/features/wallet/accounts/types'
export function SelectWallets({ flow }: { flow: ExtensionOnboardingFlow }): JSX.Element {
......@@ -36,11 +37,15 @@ export function SelectWallets({ flow }: { flow: ExtensionOnboardingFlow }): JSX.
const { importableAccounts, isLoading, showError, refetch } = useImportableAccounts(generatedAddresses)
const { eligible: isAnyAccountEligibleForDelegation, loading: isDelegationChecksLoading } =
useAnyAccountEligibleForDelegation(importableAccounts)
const { selectedAddresses, toggleAddressSelection } = useSelectAccounts(importableAccounts)
const smartWalletEnabled = useFeatureFlag(FeatureFlags.SmartWallet)
const enableSubmit = showError || (selectedAddresses.length > 0 && !isLoading)
const enableSubmit =
(showError || (selectedAddresses.length > 0 && !isLoading)) && !(isDelegationChecksLoading && smartWalletEnabled)
const onSubmit = useEvent(async () => {
if (!enableSubmit) {
......@@ -62,8 +67,8 @@ export function SelectWallets({ flow }: { flow: ExtensionOnboardingFlow }): JSX.
useSubmitOnEnter(showError ? refetch : onSubmit)
const belowFrameContent = useMemo(
() => (smartWalletEnabled ? <SmartWalletTooltip /> : undefined),
[smartWalletEnabled],
() => (smartWalletEnabled && isAnyAccountEligibleForDelegation ? <SmartWalletTooltip /> : undefined),
[smartWalletEnabled, isAnyAccountEligibleForDelegation],
)
return (
......@@ -96,7 +101,7 @@ export function SelectWallets({ flow }: { flow: ExtensionOnboardingFlow }): JSX.
<Text color="$statusCritical" textAlign="center" variant="buttonLabel2">
{t('onboarding.selectWallets.error')}
</Text>
) : isLoading ? (
) : isDelegationChecksLoading ? (
<Flex>
<SelectWalletsSkeleton repeat={3} />
</Flex>
......
......@@ -68,7 +68,7 @@ export function ConnectPopupContent({
}
const openManageConnections = (): void => {
navigateTo(`/${AppRoutes.Settings}/${SettingsRoutes.ManageConnections}`)
navigateTo(`${AppRoutes.Settings}/${SettingsRoutes.ManageConnections}`)
}
const fallbackIcon = <DappIconPlaceholder iconSize={iconSizes.icon40} name={dappUrl} />
......
......@@ -27,17 +27,11 @@ export function BiometricUnlockSettingsToggleRow(): JSX.Element | null {
const { mutate: setupBiometricUnlock } = useBiometricUnlockSetupMutation()
const { mutate: disableBiometricUnlock } = useBiometricUnlockDisableMutation()
const onPasswordModalNext = useEvent((password?: string): void => {
hidePasswordModal()
if (!password) {
return
}
const handleToggleChange = useEvent(() => {
if (hasBiometricUnlockCredential) {
disableBiometricUnlock()
} else {
setupBiometricUnlock(password)
showPasswordModal()
}
})
......@@ -51,13 +45,18 @@ export function BiometricUnlockSettingsToggleRow(): JSX.Element | null {
Icon={biometricCapabilities?.icon ?? Fingerprint}
title={biometricCapabilities?.name ?? t('common.biometrics.generic')}
checked={hasBiometricUnlockCredential}
onCheckedChange={showPasswordModal}
onCheckedChange={handleToggleChange}
/>
{isPasswordModalOpen && (
<EnterPasswordModal
isOpen={true}
onNext={onPasswordModalNext}
onNext={(password): void => {
hidePasswordModal()
if (password) {
setupBiometricUnlock(password)
}
}}
onClose={hidePasswordModal}
shouldReturnPassword
/>
......
......@@ -25,7 +25,7 @@ export function DeviceAccessScreen(): JSX.Element {
<SettingsItem
Icon={Key}
title={t('settings.setting.password.title')}
onPress={(): void => navigateTo(`/${AppRoutes.Settings}/${SettingsRoutes.ChangePassword}`)}
onPress={(): void => navigateTo(`${AppRoutes.Settings}/${SettingsRoutes.ChangePassword}`)}
/>
</ScrollView>
</Flex>
......
......@@ -32,7 +32,7 @@ export function RemoveRecoveryPhraseWallets(): JSX.Element {
title={t('setting.recoveryPhrase.remove.initial.title')}
onNextPressed={(): void => {
navigateTo(
`/${AppRoutes.Settings}/${SettingsRoutes.RemoveRecoveryPhrase}/${RemoveRecoveryPhraseRoutes.Verify}`,
`${AppRoutes.Settings}/${SettingsRoutes.RemoveRecoveryPhrase}/${RemoveRecoveryPhraseRoutes.Verify}`,
)
}}
>
......
......@@ -126,7 +126,7 @@ export function SettingsScreen(): JSX.Element {
const handleAdvancedModalClose = useCallback(() => setIsAdvancedModalOpen(false), [])
const handleSmartWalletPress = useCallback(() => {
navigateTo(`/${AppRoutes.Settings}/${SettingsRoutes.SmartWallet}`)
navigateTo(`${AppRoutes.Settings}/${SettingsRoutes.SmartWallet}`)
setIsAdvancedModalOpen(false)
}, [navigateTo])
......@@ -192,7 +192,7 @@ export function SettingsScreen(): JSX.Element {
<SettingsItem
Icon={Settings}
title="Developer Settings"
onPress={(): void => navigateTo(`/${AppRoutes.Settings}/${SettingsRoutes.DevMenu}`)}
onPress={(): void => navigateTo(`${AppRoutes.Settings}/${SettingsRoutes.DevMenu}`)}
/>
)}
</>
......@@ -285,19 +285,19 @@ export function SettingsScreen(): JSX.Element {
<SettingsItem
Icon={Lock}
title={deviceAccessScreenTitle}
onPress={(): void => navigateTo(`/${AppRoutes.Settings}/${SettingsRoutes.DeviceAccess}`)}
onPress={(): void => navigateTo(`${AppRoutes.Settings}/${SettingsRoutes.DeviceAccess}`)}
/>
) : (
<SettingsItem
Icon={Key}
title={t('settings.setting.password.title')}
onPress={(): void => navigateTo(`/${AppRoutes.Settings}/${SettingsRoutes.ChangePassword}`)}
onPress={(): void => navigateTo(`${AppRoutes.Settings}/${SettingsRoutes.ChangePassword}`)}
/>
)}
<SettingsItem
Icon={FileListLock}
title={t('settings.setting.recoveryPhrase.title')}
onPress={(): void => navigateTo(`/${AppRoutes.Settings}/${SettingsRoutes.ViewRecoveryPhrase}`)}
onPress={(): void => navigateTo(`${AppRoutes.Settings}/${SettingsRoutes.ViewRecoveryPhrase}`)}
/>
<>
{hasPasskeyBackup && (
......
......@@ -33,7 +33,7 @@ export function StorageWarningModal({ isOnboarding }: StorageWarningModalProps):
? undefined
: (): void => {
onStorageWarningClose()
navigateTo(`/${AppRoutes.Settings}/${SettingsRoutes.ViewRecoveryPhrase}`)
navigateTo(`${AppRoutes.Settings}/${SettingsRoutes.ViewRecoveryPhrase}`)
}
}
/>
......
......@@ -3,21 +3,14 @@ import { createSearchParams, useNavigate } from 'react-router-dom'
import { navigateToInterfaceFiatOnRamp } from 'src/app/features/for/utils'
import { AppRoutes, HomeQueryParams, HomeTabs } from 'src/app/navigation/constants'
import { navigate } from 'src/app/navigation/state'
import {
SidebarLocationState,
focusOrCreateTokensExploreTab,
focusOrCreateUniswapInterfaceTab,
} from 'src/app/navigation/utils'
import { uniswapUrls } from 'uniswap/src/constants/urls'
import { SidebarLocationState, focusOrCreateTokensExploreTab } from 'src/app/navigation/utils'
import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains'
import { UniverseChainId } from 'uniswap/src/features/chains/types'
import { CopyNotificationType } from 'uniswap/src/features/notifications/types'
import { WalletEventName } from 'uniswap/src/features/telemetry/constants'
import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send'
import { ShareableEntity } from 'uniswap/src/types/sharing'
import { ExplorerDataType, getExplorerLink, getPoolDetailsURL } from 'uniswap/src/utils/linking'
import { ExplorerDataType, getExplorerLink } from 'uniswap/src/utils/linking'
import { logger } from 'utilities/src/logger/logger'
import { escapeRegExp } from 'utilities/src/primitives/string'
import { useCopyToClipboard } from 'wallet/src/components/copy/useCopyToClipboard'
import {
NavigateToFiatOnRampArgs,
......@@ -50,7 +43,6 @@ export function SideBarNavigationProvider({ children }: PropsWithChildren): JSX.
const navigateToExternalProfile = useCallback(() => {
// no-op until we have an external profile screen on extension
}, [])
const navigateToPoolDetails = useNavigateToPoolDetails()
return (
<WalletNavigationProvider
......@@ -63,7 +55,6 @@ export function SideBarNavigationProvider({ children }: PropsWithChildren): JSX.
navigateToFiatOnRamp={navigateToFiatOnRamp}
navigateToNftCollection={navigateToNftCollection}
navigateToNftDetails={navigateToNftDetails}
navigateToPoolDetails={navigateToPoolDetails}
navigateToReceive={navigateToReceive}
navigateToSend={navigateToSend}
navigateToSwapFlow={navigateToSwapFlow}
......@@ -184,17 +175,6 @@ function useNavigateToTokenDetails(): (currencyId: string) => void {
}, [])
}
function useNavigateToPoolDetails(): (args: { poolId: Address; chainId: UniverseChainId }) => void {
return useCallback(async ({ poolId, chainId }: { poolId: Address; chainId: UniverseChainId }): Promise<void> => {
await focusOrCreateUniswapInterfaceTab({
url: getPoolDetailsURL(poolId, chainId),
// We want to reuse the active tab only if it's already in any other PDP.
// eslint-disable-next-line security/detect-non-literal-regexp
reuseActiveTabIfItMatches: new RegExp(`^${escapeRegExp(uniswapUrls.webInterfacePoolsUrl)}`),
})
}, [])
}
function useNavigateToNftDetails(): (args: NavigateToNftItemArgs) => void {
const { defaultChainId } = useEnabledChains()
return useCallback(
......
......@@ -24,7 +24,6 @@ import { ONE_SECOND_MS } from 'utilities/src/time/time'
import { TransactionHistoryUpdater } from 'wallet/src/features/transactions/TransactionHistoryUpdater'
import { WalletUniswapProvider } from 'wallet/src/features/transactions/contexts/WalletUniswapContext'
import { QueuedOrderModal } from 'wallet/src/features/transactions/swap/modals/QueuedOrderModal'
import { NativeWalletProvider } from 'wallet/src/features/wallet/providers/NativeWalletProvider'
export function MainContent(): JSX.Element {
const isOnboarded = useSelector(isOnboardedSelector)
......@@ -132,14 +131,12 @@ export function WebNavigation(): JSX.Element {
return (
<SideBarNavigationProvider>
<NativeWalletProvider>
<WalletUniswapProvider>
<NotificationToastWrapper />
{shouldRestoreScroll && <ScrollRestoration />}
{childrenMemo}
{isLoggedIn && <ForceUpgradeModal />}
</WalletUniswapProvider>
</NativeWalletProvider>
<WalletUniswapProvider>
<NotificationToastWrapper />
{shouldRestoreScroll && <ScrollRestoration />}
{childrenMemo}
{isLoggedIn && <ForceUpgradeModal />}
</WalletUniswapProvider>
</SideBarNavigationProvider>
)
}
......
// Shared future flags configuration for createHashRouter
export const ROUTER_FUTURE_FLAGS = {
v7_relativeSplatPath: true,
v7_fetcherPersist: true,
v7_normalizeFormMethod: true,
v7_partialHydration: true,
}
// Shared future flags configuration for RouterProvider
export const ROUTER_PROVIDER_FUTURE_FLAGS = {
v7_startTransition: true,
}
......@@ -283,6 +283,15 @@ async function handleSidebarRequest({
await portChannel.sendMessage(message)
} catch (error) {
// TODO (WALL-7202): Remove this once we handle getCapabilities requests in the background script
if (request.type === DappRequestType.GetCapabilities) {
await dappResponseMessageChannel.sendMessageToTab(senderTabInfo.id, {
type: DappResponseType.ErrorResponse,
error: serializeError(rpcErrors.methodNotSupported()),
requestId: request.requestId,
})
return
}
await openSidePanel(senderTabInfo.id, windowId)
windowIdToPendingRequestsMap.set(windowIdString, windowIdToPendingRequestsMap.get(windowIdString) ?? [])
......
import { BigNumber } from '@ethersproject/bignumber'
import { JsonRpcProvider } from '@ethersproject/providers'
import { providerErrors, serializeError } from '@metamask/rpc-errors'
import { dappStore } from 'src/app/features/dapp/store'
......@@ -211,7 +210,7 @@ addWindowMessageListener<WindowEthereumRequest>({
externalDappMessageChannel.addMessageListener(ExtensionToDappRequestType.SwitchChain, (message) => {
setChainIdAndMaybeEmit(message.chainId)
setProvider(new JsonRpcProvider(message.providerUrl, BigNumber.from(message.chainId).toString()))
setProvider(new JsonRpcProvider(message.providerUrl, parseInt(message.chainId)))
})
externalDappMessageChannel.addMessageListener(ExtensionToDappRequestType.UpdateConnections, (message) => {
......
/* eslint-disable max-lines */
import { BigNumber } from '@ethersproject/bignumber'
import { JsonRpcProvider } from '@ethersproject/providers'
import { getPermissions } from 'src/app/features/dappRequests/permissions'
import { SendTransactionRequest } from 'src/app/features/dappRequests/types/DappRequestTypes'
......@@ -129,7 +128,7 @@ export class ExtensionEthMethodHandler extends BaseMethodHandler<WindowEthereumR
})?.source
this.setChainIdAndMaybeEmit(message.chainId)
this.setProvider(new JsonRpcProvider(message.providerUrl, BigNumber.from(message.chainId).toString()))
this.setProvider(new JsonRpcProvider(message.providerUrl, parseInt(message.chainId)))
source?.postMessage({
requestId: message.requestId,
result: message.chainId,
......@@ -289,7 +288,7 @@ export class ExtensionEthMethodHandler extends BaseMethodHandler<WindowEthereumR
}): void {
this.setConnectedAddressesAndMaybeEmit(connectedAddresses)
this.setChainIdAndMaybeEmit(chainId)
this.setProvider(new JsonRpcProvider(providerUrl, BigNumber.from(chainId).toString()))
this.setProvider(new JsonRpcProvider(providerUrl, parseInt(chainId)))
}
// eslint-disable-next-line complexity
......
......@@ -2,7 +2,7 @@
"manifest_version": 3,
"name": "Uniswap Extension",
"description": "The Uniswap Extension is a self-custody crypto wallet that's built for swapping.",
"version": "1.24.0",
"version": "1.23.0",
"minimum_chrome_version": "116",
"icons": {
"16": "assets/icon16.png",
......
......@@ -71,9 +71,9 @@ if (isCI && datadogPropertiesAvailable) {
apply from: "../../../../node_modules/@datadog/mobile-react-native/datadog-sourcemaps.gradle"
}
def devVersionName = "1.54"
def betaVersionName = "1.54"
def prodVersionName = "1.54"
def devVersionName = "1.53"
def betaVersionName = "1.53"
def prodVersionName = "1.53"
android {
ndkVersion rootProject.ext.ndkVersion
......
......@@ -4,7 +4,7 @@
"private": true,
"license": "GPL-3.0-or-later",
"scripts": {
"android": "rnef run:android --variant=devDebug --app-id-suffix=dev && yarn start",
"android": "rnef run:android --variant=devDebug --app-id-suffix=dev",
"android:release": "rnef run:android --variant=devRelease --app-id-suffix=dev",
"android:beta": "rnef run:android --variant=betaDebug --app-id-suffix=beta",
"android:beta:release": "rnef run:android --variant=betaRelease --app-id-suffix=beta",
......@@ -30,7 +30,7 @@
"firestore:deploy:rules": "firebase deploy --only firestore:rules",
"link:assets": "react-native-asset",
"check:circular": "../../scripts/check-circular-imports.sh ./src/app/App.tsx 0",
"ios": "rnef run:ios --scheme Uniswap --configuration Debug && yarn start",
"ios": "rnef run:ios --scheme Uniswap --configuration Debug",
"ios:smol": "rnef run:ios --device=\"iPhone SE (3rd generation)\"",
"ios:dev:release": "rnef run:ios --configuration Dev",
"ios:beta": "rnef run:ios --configuration Beta",
......@@ -39,7 +39,7 @@
"format": "../../scripts/prettier.sh",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx --max-warnings=0",
"lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix",
"start": "NODE_ENV=development rnef start --client-logs",
"start": "NODE_ENV=development rnef start",
"start:production": "NODE_ENV=production rnef start --reset-cache",
"test": "node --max-old-space-size=8912 ../../node_modules/.bin/jest",
"snapshots": "jest -u",
......@@ -178,6 +178,7 @@
"@babel/plugin-proposal-numeric-separator": "7.16.7",
"@babel/runtime": "7.26.0",
"@datadog/datadog-ci": "2.39.0",
"@faker-js/faker": "7.6.0",
"@react-native-community/datetimepicker": "8.2.0",
"@react-native-community/slider": "4.5.5",
"@storybook/addon-ondevice-controls": "8.5.2",
......
#!/bin/bash
MAX_SIZE=23.44
MAX_SIZE=23.33
MAX_BUFFER=0.5
# Check OS type and use appropriate stat command
......
......@@ -87,8 +87,7 @@ import { WalletUniswapProvider } from 'wallet/src/features/transactions/contexts
import { Account } from 'wallet/src/features/wallet/accounts/types'
import { WalletContextProvider } from 'wallet/src/features/wallet/context'
import { useAccounts } from 'wallet/src/features/wallet/hooks'
import { NativeWalletProvider } from 'wallet/src/features/wallet/providers/NativeWalletProvider'
import { SharedWalletProvider as SharedWalletReduxProvider } from 'wallet/src/providers/SharedWalletProvider'
import { SharedWalletProvider } from 'wallet/src/providers/SharedWalletProvider'
enableFreeze(true)
......@@ -153,14 +152,14 @@ function App(): JSX.Element | null {
<StrictMode>
<I18nextProvider i18n={i18n}>
<SafeAreaProvider>
<SharedWalletReduxProvider reduxStore={store}>
<SharedWalletProvider reduxStore={store}>
<AnalyticsNavigationContextProvider
shouldLogScreen={shouldLogScreen}
useIsPartOfNavigationTree={useIsPartOfNavigationTree}
>
<AppOuter />
</AnalyticsNavigationContextProvider>
</SharedWalletReduxProvider>
</SharedWalletProvider>
</SafeAreaProvider>
</I18nextProvider>
</StrictMode>
......@@ -255,16 +254,14 @@ function AppOuter(): JSX.Element | null {
<DataUpdaters />
<NavigationContainer>
<MobileWalletNavigationProvider>
<NativeWalletProvider>
<WalletUniswapProvider>
<BottomSheetModalProvider>
<AppModals />
<PerformanceProfiler onReportPrepared={onReportPrepared}>
<AppInner />
</PerformanceProfiler>
</BottomSheetModalProvider>
</WalletUniswapProvider>
</NativeWalletProvider>
<WalletUniswapProvider>
<BottomSheetModalProvider>
<AppModals />
<PerformanceProfiler onReportPrepared={onReportPrepared}>
<AppInner />
</PerformanceProfiler>
</BottomSheetModalProvider>
</WalletUniswapProvider>
<NotificationToastWrapper />
</MobileWalletNavigationProvider>
</NavigationContainer>
......
......@@ -18,7 +18,6 @@ import { ShareableEntity } from 'uniswap/src/types/sharing'
import { buildCurrencyId } from 'uniswap/src/utils/currencyId'
import { closeKeyboardBeforeCallback } from 'utilities/src/device/keyboard/dismissNativeKeyboard'
import { logger } from 'utilities/src/logger/logger'
import noop from 'utilities/src/react/noop'
import { ScannerModalState } from 'wallet/src/components/QRCodeScanner/constants'
import {
NavigateToExternalProfileArgs,
......@@ -66,7 +65,6 @@ export function MobileWalletNavigationProvider({ children }: PropsWithChildren):
navigateToSend={navigateToSend}
navigateToSwapFlow={navigateToSwapFlow}
navigateToTokenDetails={navigateToTokenDetails}
navigateToPoolDetails={noop} // no pool details screen on mobile
>
{children}
</WalletNavigationProvider>
......
......@@ -7,7 +7,7 @@ import {
import { NativeStackNavigationProp, NativeStackScreenProps } from '@react-navigation/native-stack'
import { TokenWarningModalState } from 'src/app/modals/TokenWarningModalState'
import { RemoveWalletModalState } from 'src/components/RemoveWallet/RemoveWalletModalState'
import { RestoreWalletModalState, WalletRestoreType } from 'src/components/RestoreWalletModal/RestoreWalletModalState'
import { RestoreWalletModalState } from 'src/components/RestoreWalletModal/RestoreWalletModalState'
import { ConnectionsDappsListModalState } from 'src/components/Settings/ConnectionsDappModal/ConnectionsDappsListModalState'
import { EditWalletSettingsModalState } from 'src/components/Settings/EditWalletModal/EditWalletSettingsModalState'
import { ManageWalletsModalState } from 'src/components/Settings/ManageWalletsModalState'
......@@ -113,7 +113,6 @@ export type SettingsStackParamList = {
export type OnboardingStackBaseParams = {
importType: ImportType
entryPoint: OnboardingEntryPoint
restoreType?: WalletRestoreType
}
export type OnboardingStackParamList = {
......
......@@ -11,8 +11,7 @@ import { ArrowDownCircleFilledWithBorder, WalletFilled } from 'ui/src/components
import { spacing } from 'ui/src/theme'
import { GenericHeader } from 'uniswap/src/components/misc/GenericHeader'
import { Modal } from 'uniswap/src/components/modals/Modal'
import { ElementName, ModalName } from 'uniswap/src/features/telemetry/constants'
import Trace from 'uniswap/src/features/telemetry/Trace'
import { ModalName } from 'uniswap/src/features/telemetry/constants'
import { TestID } from 'uniswap/src/test/fixtures/testIDs'
import { ImportType, OnboardingEntryPoint } from 'uniswap/src/types/onboarding'
import { MobileScreens, OnboardingScreens } from 'uniswap/src/types/screens/mobile'
......@@ -36,21 +35,19 @@ export function RestoreWalletModal({ route }: AppStackScreenProp<typeof ModalNam
const { onClose } = useReactNavigationModal()
const restoreType = route.params.restoreType
const { title, description, isDismissible, modalName } = useMemo(() => {
const { title, description, isDismissible } = useMemo(() => {
switch (restoreType) {
case WalletRestoreType.SeedPhrase:
return {
title: t('account.wallet.restore.seed_phrase.title'),
description: t('account.wallet.restore.seed_phrase.description'),
isDismissible: true,
modalName: ModalName.RestoreWalletSeedPhrase,
}
case WalletRestoreType.NewDevice:
return {
title: t('account.wallet.restore.new_device.title'),
description: t('account.wallet.restore.new_device.description'),
isDismissible: false,
modalName: ModalName.RestoreWallet,
}
default:
return {}
......@@ -68,7 +65,6 @@ export function RestoreWalletModal({ route }: AppStackScreenProp<typeof ModalNam
params: {
entryPoint: OnboardingEntryPoint.Sidebar,
importType: ImportType.RestoreMnemonic,
restoreType,
},
})
break
......@@ -79,7 +75,6 @@ export function RestoreWalletModal({ route }: AppStackScreenProp<typeof ModalNam
params: {
entryPoint: OnboardingEntryPoint.Sidebar,
importType: ImportType.RestoreMnemonic,
restoreType,
},
})
break
......@@ -92,7 +87,7 @@ export function RestoreWalletModal({ route }: AppStackScreenProp<typeof ModalNam
hideHandlebar
backgroundColor={colors.surface1.val}
isDismissible={isDismissible}
name={modalName ?? ModalName.RestoreWallet}
name={ModalName.RestoreWallet}
onClose={onClose}
>
<Flex centered gap="$spacing24" px="$spacing24" py="$spacing12" backgroundColor="$surface1">
......@@ -132,19 +127,15 @@ export function RestoreWalletModal({ route }: AppStackScreenProp<typeof ModalNam
<GenericHeader title={title} titleVariant="subheading1" subtitle={description} subtitleVariant="body3" />
<Flex gap="$spacing8" width="100%">
<Flex row>
<Trace logPress element={ElementName.Continue}>
<Button testID={TestID.Continue} variant="branded" emphasis="primary" size="medium" onPress={onRestore}>
{t('common.button.continue')}
</Button>
</Trace>
<Button testID={TestID.Continue} variant="branded" emphasis="primary" size="medium" onPress={onRestore}>
{t('common.button.continue')}
</Button>
</Flex>
{isDismissible && (
<Flex row>
<Trace logPress element={ElementName.Cancel}>
<Button testID={TestID.Cancel} variant="default" emphasis="secondary" size="medium" onPress={onClose}>
{t('common.button.notNow')}
</Button>
</Trace>
<Button testID={TestID.Cancel} variant="default" emphasis="secondary" size="medium" onPress={onClose}>
{t('common.button.notNow')}
</Button>
</Flex>
)}
</Flex>
......
import { memo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { KeyboardAvoidingView } from 'react-native'
import { memo, useCallback, useState } from 'react'
import { KeyboardAvoidingView, TextInput } from 'react-native'
import { FlatList } from 'react-native-gesture-handler'
import { SearchEmptySection } from 'src/components/explore/search/SearchEmptySection'
import { SearchResultsSection } from 'src/components/explore/search/SearchResultsSection'
import { Flex, Text, TouchableArea, flexStyles } from 'ui/src'
import { useTranslation } from 'react-i18next'
import { UniverseChainId } from 'uniswap/src/features/chains/types'
import { FeatureFlags } from 'uniswap/src/features/gating/flags'
import { useFeatureFlag } from 'uniswap/src/features/gating/hooks'
import { SearchModalNoQueryList } from 'uniswap/src/features/search/SearchModal/SearchModalNoQueryList'
import { SearchModalResultsList } from 'uniswap/src/features/search/SearchModal/SearchModalResultsList'
import { MOBILE_SEARCH_TABS, SearchTab } from 'uniswap/src/features/search/SearchModal/types'
import Trace from 'uniswap/src/features/telemetry/Trace'
import { ElementName, SectionName } from 'uniswap/src/features/telemetry/constants'
import { SectionName } from 'uniswap/src/features/telemetry/constants'
import { useDebounce } from 'utilities/src/time/timing'
export const ExploreScreenSearchResultsList = memo(function _ExploreScreenSearchResultsList({
function LegacyExploreSearchResultsList({
debouncedSearchQuery,
chainFilter,
textInputRef,
}: {
debouncedSearchQuery: string
chainFilter: UniverseChainId | null
textInputRef: React.RefObject<TextInput>
}): JSX.Element {
const onScroll = useCallback(() => {
textInputRef.current?.blur()
}, [textInputRef])
return (
<>
<Flex p="$spacing4" />
{debouncedSearchQuery.length === 0 ? (
// Mimic ScrollView behavior with FlatList
// Needs to be from gesture handler to work on android within BottomSheelModal
<FlatList
ListHeaderComponent={<SearchEmptySection selectedChain={chainFilter} />}
data={[]}
keyExtractor={(): string => 'search-empty-section-container'}
keyboardShouldPersistTaps="always" // TODO(WALL-6724): does not actually work; behaves as default/"never"
renderItem={null}
scrollEventThrottle={16}
showsHorizontalScrollIndicator={false}
showsVerticalScrollIndicator={false}
onScroll={onScroll}
/>
) : (
<SearchResultsSection searchQuery={debouncedSearchQuery} selectedChain={chainFilter} />
)}
</>
)
}
function NewExploreSearchResultsList({
searchQuery,
parsedSearchQuery,
chainFilter,
parsedChainFilter,
debouncedSearchQuery,
debouncedParsedSearchQuery,
}: {
searchQuery: string
parsedSearchQuery: string | null
chainFilter: UniverseChainId | null
parsedChainFilter: UniverseChainId | null
debouncedSearchQuery: string | null
debouncedParsedSearchQuery: string | null
}): JSX.Element {
const debouncedSearchQuery = useDebounce(searchQuery)
const debouncedParsedSearchQuery = useDebounce(parsedSearchQuery)
const { t } = useTranslation()
const [activeTab, setActiveTab] = useState<SearchTab>(SearchTab.All)
......@@ -43,33 +84,64 @@ export const ExploreScreenSearchResultsList = memo(function _ExploreScreenSearch
}
}
return (
<Trace section={SectionName.ExploreSearch}>
<Flex row px="$spacing20" pt="$spacing16" pb="$spacing8" gap="$spacing16">
{MOBILE_SEARCH_TABS.map((tab) => (
<TouchableArea key={tab} onPress={() => setActiveTab(tab)}>
<Text color={activeTab === tab ? '$neutral1' : '$neutral2'} variant="buttonLabel2">
{getTabLabel(tab)}
</Text>
</TouchableArea>
))}
</Flex>
{searchQuery && searchQuery.length > 0 ? (
<SearchModalResultsList
chainFilter={chainFilter}
debouncedParsedSearchFilter={debouncedParsedSearchQuery}
debouncedSearchFilter={debouncedSearchQuery}
searchFilter={searchQuery}
activeTab={activeTab}
/>
) : (
<SearchModalNoQueryList chainFilter={chainFilter} activeTab={activeTab} />
)}
</Trace>
)
}
export const ExploreScreenSearchResultsList = memo(function _ExploreScreenSearchResultsList({
searchQuery,
parsedSearchQuery,
chainFilter,
textInputRef,
}: {
searchQuery: string
parsedSearchQuery: string | null
chainFilter: UniverseChainId | null
textInputRef: React.RefObject<TextInput>
}): JSX.Element {
const searchRevampEnabled = useFeatureFlag(FeatureFlags.SearchRevamp)
const debouncedSearchQuery = useDebounce(searchQuery)
const debouncedParsedSearchQuery = useDebounce(parsedSearchQuery)
return (
<KeyboardAvoidingView behavior="height" style={flexStyles.fill}>
<Trace section={SectionName.ExploreSearch}>
<Flex row px="$spacing20" pt="$spacing16" pb="$spacing8" gap="$spacing16">
{MOBILE_SEARCH_TABS.map((tab) => (
<Trace key={tab} logPress element={ElementName.SearchTab} properties={{ search_tab: tab }}>
<TouchableArea onPress={() => setActiveTab(tab)}>
<Text color={activeTab === tab ? '$neutral1' : '$neutral2'} variant="buttonLabel2">
{getTabLabel(tab)}
</Text>
</TouchableArea>
</Trace>
))}
</Flex>
{searchQuery && searchQuery.length > 0 ? (
<SearchModalResultsList
chainFilter={chainFilter}
parsedChainFilter={parsedChainFilter}
debouncedParsedSearchFilter={debouncedParsedSearchQuery}
debouncedSearchFilter={debouncedSearchQuery}
searchFilter={searchQuery}
activeTab={activeTab}
/>
) : (
<SearchModalNoQueryList chainFilter={chainFilter} activeTab={activeTab} />
)}
</Trace>
{searchRevampEnabled ? (
<NewExploreSearchResultsList
searchQuery={searchQuery}
chainFilter={chainFilter}
debouncedSearchQuery={debouncedSearchQuery}
debouncedParsedSearchQuery={debouncedParsedSearchQuery}
/>
) : (
<LegacyExploreSearchResultsList
debouncedSearchQuery={debouncedParsedSearchQuery ?? debouncedSearchQuery}
chainFilter={chainFilter}
textInputRef={textInputRef}
/>
)}
</KeyboardAvoidingView>
)
})
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { FlatList } from 'react-native-gesture-handler'
import { FadeIn, FadeOut } from 'react-native-reanimated'
import { useDispatch, useSelector } from 'react-redux'
import { SearchPopularNFTCollections } from 'src/components/explore/search/SearchPopularNFTCollections'
import { SearchPopularTokens } from 'src/components/explore/search/SearchPopularTokens'
import { renderSearchItem } from 'src/components/explore/search/SearchResultsSection'
import { SectionHeaderText } from 'src/components/explore/search/SearchSectionHeader'
import { Flex, Text, TouchableArea, useSporeColors } from 'ui/src'
import { Clock, InfoCircleFilled, Star, TrendUp } from 'ui/src/components/icons'
import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex'
import { WarningModal } from 'uniswap/src/components/modals/WarningModal/WarningModal'
import { WarningSeverity } from 'uniswap/src/components/modals/WarningModal/types'
import { UniverseChainId } from 'uniswap/src/features/chains/types'
import { clearSearchHistory } from 'uniswap/src/features/search/searchHistorySlice'
import { selectSearchHistory } from 'uniswap/src/features/search/selectSearchHistory'
import { ModalName } from 'uniswap/src/features/telemetry/constants'
import { dismissNativeKeyboard } from 'utilities/src/device/keyboard/dismissNativeKeyboard'
const TrendUpIcon = <TrendUp color="$neutral2" size="$icon.24" />
export function SearchEmptySection({ selectedChain }: { selectedChain: UniverseChainId | null }): JSX.Element {
const { t } = useTranslation()
const colors = useSporeColors()
const dispatch = useDispatch()
const searchHistory = useSelector(selectSearchHistory)
const [showPopularInfo, setShowPopularInfo] = useState(false)
// Popular NFT collections data is only available on Mainnet
// TODO(WALL-5876): Update this once we have a way to fetch NFT collections for all chains
const showPopularNftCollections = !selectedChain || selectedChain === UniverseChainId.Mainnet
const onPressClearSearchHistory = (): void => {
dispatch(clearSearchHistory())
}
const onPopularTokenInfoPress = (): void => {
dismissNativeKeyboard()
setShowPopularInfo(true)
}
// Show search history (if applicable), trending tokens, and wallets
return (
<>
<AnimatedFlex entering={FadeIn} exiting={FadeOut} gap="$spacing12" pb="$spacing36">
{searchHistory.length > 0 && (
<AnimatedFlex entering={FadeIn} exiting={FadeOut}>
<FlatList
ListHeaderComponent={
<Flex
row
alignItems="center"
gap="$spacing16"
justifyContent="space-between"
mb="$spacing4"
pr="$spacing20"
>
<SectionHeaderText icon={<Clock size="$icon.20" />} title={t('explore.search.section.recent')} />
<TouchableArea onPress={onPressClearSearchHistory}>
<Text color="$accent1" variant="buttonLabel2">
{t('explore.search.action.clear')}
</Text>
</TouchableArea>
</Flex>
}
data={searchHistory}
renderItem={(props): JSX.Element | null =>
renderSearchItem({ ...props, searchContext: { isHistory: true } })
}
/>
</AnimatedFlex>
)}
<Flex gap="$spacing4">
<SectionHeaderText
afterIcon={<InfoCircleFilled color="$neutral2" size="$icon.16" />}
icon={TrendUpIcon}
title={t('explore.search.section.popularTokens')}
onPress={onPopularTokenInfoPress}
/>
<SearchPopularTokens selectedChain={selectedChain} />
</Flex>
{showPopularNftCollections && (
<Flex gap="$spacing4">
<SectionHeaderText icon={TrendUpIcon} title={t('explore.search.section.popularNFT')} />
<SearchPopularNFTCollections />
</Flex>
)}
</AnimatedFlex>
<WarningModal
backgroundIconColor={colors.surface3.get()}
caption={t('explore.search.section.popularTokenInfo')}
rejectText={t('common.button.close')}
icon={<Star color="$neutral1" size="$icon.24" />}
isOpen={showPopularInfo}
modalName={ModalName.NetworkFeeInfo}
severity={WarningSeverity.None}
title={t('explore.search.section.popularTokens')}
onClose={() => setShowPopularInfo(false)}
/>
</>
)
}
import React, { useMemo } from 'react'
import { ListRenderItemInfo } from 'react-native'
import { FlatList } from 'react-native-gesture-handler'
import { SEARCH_ITEM_PX, SEARCH_ITEM_PY } from 'src/components/explore/search/constants'
import { SearchNFTCollectionItem } from 'src/components/explore/search/items/SearchNFTCollectionItem'
import { getSearchResultId, gqlNFTToNFTCollectionSearchResult } from 'src/components/explore/search/utils'
import { Flex, Loader } from 'ui/src'
import { useSearchPopularNftCollectionsQuery } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks'
import { NFTCollectionSearchResult, SearchResultType } from 'uniswap/src/features/search/SearchResult'
function isNFTCollectionSearchResult(result: NFTCollectionSearchResult | null): result is NFTCollectionSearchResult {
return result?.type === SearchResultType.NFTCollection
}
export function SearchPopularNFTCollections(): JSX.Element {
// Load popular NFTs by top trading volume
const { data, loading } = useSearchPopularNftCollectionsQuery()
const formattedItems = useMemo(() => {
if (!data?.topCollections?.edges) {
return undefined
}
const searchResults = data.topCollections.edges.map(({ node }) => gqlNFTToNFTCollectionSearchResult(node))
return searchResults.filter(isNFTCollectionSearchResult)
}, [data])
if (loading) {
return (
<Flex px={SEARCH_ITEM_PX} py={SEARCH_ITEM_PY}>
<Loader.Token repeat={2} />
</Flex>
)
}
return <FlatList data={formattedItems} keyExtractor={getSearchResultId} renderItem={renderNFTCollectionItem} />
}
const renderNFTCollectionItem = ({ item }: ListRenderItemInfo<NFTCollectionSearchResult>): JSX.Element => (
<SearchNFTCollectionItem collection={item} />
)
import React from 'react'
import { SearchPopularTokens } from 'src/components/explore/search/SearchPopularTokens'
import { render, screen } from 'src/test/test-utils'
import { UniverseChainId } from 'uniswap/src/features/chains/types'
import { ethToken, usdcToken, wethToken } from 'uniswap/src/test/fixtures'
import { queryResolvers } from 'uniswap/src/test/utils'
import { ONE_SECOND_MS } from 'utilities/src/time/time'
const { resolvers } = queryResolvers({
topTokens: () => [wethToken(), usdcToken()],
tokens: () => [ethToken({ address: undefined })],
})
describe(SearchPopularTokens, () => {
// TODO(MOB-3146): this test is flaky
jest.retryTimes(3)
it.skip('renders without error', async () => {
const tree = render(<SearchPopularTokens selectedChain={UniverseChainId.Mainnet} />, { resolvers })
// Loading should show Token loader
expect(screen.getAllByText('Token Full Name')).toBeDefined()
expect(tree.toJSON()).toMatchSnapshot()
// Success where WETH result in topTokens is replaced by ETH
expect(await screen.findByText('ETH', {}, { timeout: ONE_SECOND_MS * 3 })).toBeDefined()
expect(screen.getByText('USDC')).toBeDefined()
expect(tree.toJSON()).toMatchSnapshot()
})
})
import { TokenRankingsStat } from '@uniswap/client-explore/dist/uniswap/explore/v1/service_pb'
import React, { useMemo } from 'react'
import { ListRenderItemInfo } from 'react-native'
import { FlatList } from 'react-native-gesture-handler'
import { SEARCH_ITEM_PX, SEARCH_ITEM_PY } from 'src/components/explore/search/constants'
import { SearchTokenItem } from 'src/components/explore/search/items/SearchTokenItem'
import { getSearchResultId } from 'src/components/explore/search/utils'
import { Flex, Loader } from 'ui/src'
import { MAX_DEFAULT_TRENDING_TOKEN_RESULTS_AMOUNT } from 'uniswap/src/components/TokenSelector/constants'
import { ProtectionResult } 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 { RankingType } from 'uniswap/src/data/types'
import { UniverseChainId } from 'uniswap/src/features/chains/types'
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'
function tokenStatsToTokenSearchResult(token: Maybe<TokenRankingsStat>): TokenSearchResult | null {
if (!token) {
return null
}
const { chain, address, symbol, name, logo } = token
const chainId = fromGraphQLChain(chain)
if (!chainId || !symbol || !name) {
return null
}
return {
type: SearchResultType.Token,
chainId,
address,
name,
symbol,
logoUrl: logo ?? null,
// BE has confirmed that all of these TokenRankingsStat tokens are Verified SafetyLevel, and design confirmed that we can hide the warning icon here
safetyInfo: {
tokenList: TokenList.Default,
attackType: undefined,
protectionResult: ProtectionResult.Benign,
},
feeData: null,
}
}
export function SearchPopularTokens({ selectedChain }: { selectedChain: UniverseChainId | null }): JSX.Element {
const { data, isLoading } = useTokenRankingsQuery({
chainId: selectedChain?.toString() ?? ALL_NETWORKS_ARG,
})
const popularTokens = data?.tokenRankings[RankingType.Popularity]?.tokens.slice(
0,
MAX_DEFAULT_TRENDING_TOKEN_RESULTS_AMOUNT,
)
const formattedTokens = useMemo(
() => popularTokens?.map(tokenStatsToTokenSearchResult).filter((t): t is TokenSearchResult => Boolean(t)),
[popularTokens],
)
if (isLoading) {
return (
<Flex px={SEARCH_ITEM_PX} py={SEARCH_ITEM_PY}>
<Loader.Token repeat={2} />
</Flex>
)
}
return <FlatList data={formattedTokens} keyExtractor={getSearchResultId} renderItem={renderTokenItem} />
}
const renderTokenItem = ({ item }: ListRenderItemInfo<TokenSearchResult>): JSX.Element => (
<SearchTokenItem token={item} />
)
import React from 'react'
import {
NFTHeaderItem,
SEARCH_ITEM_PX,
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 { UniverseChainId } from 'uniswap/src/features/chains/types'
function SectionLoader({ searchHeader, repeat = 1 }: { searchHeader: SearchHeader; repeat?: number }): JSX.Element {
return (
<Flex gap="$spacing12">
<SectionHeaderText icon={searchHeader.icon} title={searchHeader.title} />
<Flex px={SEARCH_ITEM_PX}>
<Loader.SearchResult repeat={repeat} />
</Flex>
</Flex>
)
}
/**
* 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 (
<Flex gap="$spacing16">
<SectionLoader searchHeader={TokenHeaderItem} repeat={2} />
<SectionLoader searchHeader={WalletHeaderItem} />
{!hideNftLoading && <SectionLoader searchHeader={NFTHeaderItem} repeat={2} />}
</Flex>
)
}
import React from 'react'
import { Flex, FlexProps, Text, TouchableArea } from 'ui/src'
interface SectionHeaderTextProps {
title: string
icon?: JSX.Element
afterIcon?: JSX.Element
onPress?: () => void
}
export const SectionHeaderText = ({
title,
icon,
afterIcon,
onPress,
...rest
}: SectionHeaderTextProps & FlexProps): JSX.Element => {
return (
<TouchableArea disabled={!onPress} onPress={onPress}>
<Flex row alignItems="center" gap="$spacing4" mb="$spacing4" mx="$spacing20" {...rest}>
{icon && icon}
<Text color="$neutral2" pl={icon ? '$spacing4' : '$none'} variant="subheading2">
{title}
</Text>
{afterIcon && afterIcon}
</Flex>
</TouchableArea>
)
}
import { SearchHeader, SearchHeaderKey } from 'src/components/explore/search/types'
import { Coin, Gallery, Person } from 'ui/src/components/icons'
import { iconSizes } from 'ui/src/theme'
import { getChainInfo } from 'uniswap/src/features/chains/chainInfo'
import { UniverseChainId } from 'uniswap/src/features/chains/types'
import i18n from 'uniswap/src/i18n'
export const SEARCH_RESULT_HEADER_KEY: SearchHeaderKey = 'header'
export const SEARCH_ITEM_PX = '$spacing20'
export const SEARCH_ITEM_PY = '$spacing8'
export const SEARCH_ITEM_ICON_SIZE = iconSizes.icon36
const ICON_SIZE = '$icon.24'
const ICON_COLOR = '$neutral2'
export const WalletHeaderItem: SearchHeader = {
icon: <Person color={ICON_COLOR} size={ICON_SIZE} />,
type: SEARCH_RESULT_HEADER_KEY,
title: i18n.t('explore.search.section.wallets'),
}
export const TokenHeaderItem: SearchHeader = {
icon: <Coin color={ICON_COLOR} size={ICON_SIZE} />,
type: SEARCH_RESULT_HEADER_KEY,
title: i18n.t('explore.search.section.tokens'),
}
export const NFTHeaderItem: SearchHeader = {
icon: <Gallery color={ICON_COLOR} size={ICON_SIZE} />,
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: getChainInfo(chainId).explorer.name,
}),
})
import React from 'react'
import { useTranslation } from 'react-i18next'
import { SEARCH_ITEM_ICON_SIZE, SEARCH_ITEM_PX, SEARCH_ITEM_PY } from 'src/components/explore/search/constants'
import { SearchWalletItemBase } from 'src/components/explore/search/items/SearchWalletItemBase'
import { Flex, Text } from 'ui/src'
import { AccountIcon } from 'uniswap/src/features/accounts/AccountIcon'
import { useENSAvatar, useENSName } from 'uniswap/src/features/ens/api'
import { getCompletedENSName } from 'uniswap/src/features/ens/useENS'
import { SearchContext } from 'uniswap/src/features/search/SearchModal/analytics/SearchContext'
import { ENSAddressSearchResult } from 'uniswap/src/features/search/SearchResult'
import { sanitizeAddressText } from 'uniswap/src/utils/addresses'
import { shortenAddress } from 'utilities/src/addresses'
type SearchENSAddressItemProps = {
searchResult: ENSAddressSearchResult
searchContext?: SearchContext
}
export function SearchENSAddressItem({ searchResult, searchContext }: SearchENSAddressItemProps): JSX.Element {
const { t } = useTranslation()
// Use `savedPrimaryEnsName` for WalletSearchResults that are stored in the search history
// so that we don't have to do an additional ENS fetch when loading search history
const { address, ensName, primaryENSName: savedPrimaryENSName, isRawName } = searchResult
const formattedAddress = sanitizeAddressText(shortenAddress(address))
// Get the completed name if it's not a raw name
const completedENSName = isRawName ? ensName : getCompletedENSName(ensName)
/*
* Fetch primary ENS associated with `address` since it may resolve to an
* ENS different than the `ensName` searched
* ex. if searching `uni.eth` resolves to 0x123, and the primary ENS for 0x123
* is `uniswap.eth`, then we should show "uni.eth | owned by uniswap.eth"
*/
const { data: fetchedPrimaryENSName, isLoading: isFetchingPrimaryENSName } = useENSName(
savedPrimaryENSName ? undefined : address,
)
const primaryENSName = savedPrimaryENSName ?? fetchedPrimaryENSName
const isPrimaryENSName = completedENSName === primaryENSName
const showOwnedBy = !isFetchingPrimaryENSName && !isPrimaryENSName
const showAddress = !showOwnedBy
const { data: avatar } = useENSAvatar(address)
return (
<SearchWalletItemBase searchContext={searchContext} searchResult={searchResult}>
<Flex row alignItems="center" gap="$spacing12" px={SEARCH_ITEM_PX} py={SEARCH_ITEM_PY}>
<AccountIcon address={address} avatarUri={avatar} size={SEARCH_ITEM_ICON_SIZE} />
<Flex shrink>
<Text ellipsizeMode="tail" numberOfLines={1} testID={`address-display/name/${ensName}`} variant="body1">
{completedENSName || formattedAddress}
</Text>
<Text color="$neutral2" ellipsizeMode="tail" numberOfLines={1} variant="subheading2">
{showOwnedBy &&
t('explore.search.label.ownedBy', {
ownerAddress: primaryENSName || formattedAddress,
})}
{showAddress && formattedAddress}
</Text>
</Flex>
</Flex>
</SearchWalletItemBase>
)
}
import { default as React } from 'react'
import { useDispatch } from 'react-redux'
import { SEARCH_ITEM_ICON_SIZE, SEARCH_ITEM_PX, SEARCH_ITEM_PY } from 'src/components/explore/search/constants'
import { getBlockExplorerIcon } from 'src/components/icons/BlockExplorerIcon'
import { Flex, Text, TouchableArea, useSporeColors } from 'ui/src'
import { Arrow } from 'ui/src/components/arrow/Arrow'
import { iconSizes } from 'ui/src/theme'
import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains'
import { EtherscanSearchResult } from 'uniswap/src/features/search/SearchResult'
import { addToSearchHistory } from 'uniswap/src/features/search/searchHistorySlice'
import { TestID } from 'uniswap/src/test/fixtures/testIDs'
import { ExplorerDataType, getExplorerLink, openUri } from 'uniswap/src/utils/linking'
import { shortenAddress } from 'utilities/src/addresses'
type SearchEtherscanItemProps = {
etherscanResult: EtherscanSearchResult
}
export function SearchEtherscanItem({ etherscanResult }: SearchEtherscanItemProps): JSX.Element {
const colors = useSporeColors()
const dispatch = useDispatch()
const { defaultChainId } = useEnabledChains()
const { address } = etherscanResult
const onPressViewEtherscan = async (): Promise<void> => {
const explorerLink = getExplorerLink({ chainId: defaultChainId, data: address, type: ExplorerDataType.ADDRESS })
await openUri({ uri: explorerLink })
dispatch(
addToSearchHistory({
searchResult: etherscanResult,
}),
)
}
const EtherscanIcon = getBlockExplorerIcon(defaultChainId)
return (
<TouchableArea testID={TestID.SearchEtherscanItem} onPress={onPressViewEtherscan}>
<Flex
row
alignItems="center"
gap="$spacing12"
justifyContent="space-between"
px={SEARCH_ITEM_PX}
py={SEARCH_ITEM_PY}
>
<Flex centered row gap="$spacing12">
<EtherscanIcon size={SEARCH_ITEM_ICON_SIZE} />
<Text variant="body1">{shortenAddress(address)}</Text>
</Flex>
<Arrow color={colors.neutral2.val} direction="ne" size={iconSizes.icon24} />
</Flex>
</TouchableArea>
)
}
import { default as React } from 'react'
import { useDispatch } from 'react-redux'
import { useAppStackNavigation } from 'src/app/navigation/types'
import { SEARCH_ITEM_ICON_SIZE, SEARCH_ITEM_PX, SEARCH_ITEM_PY } from 'src/components/explore/search/constants'
import { Flex, Text, TouchableArea } from 'ui/src'
import { Verified } from 'ui/src/components/icons'
import { SearchContext } from 'uniswap/src/features/search/SearchModal/analytics/SearchContext'
import { NFTCollectionSearchResult, SearchResultType } from 'uniswap/src/features/search/SearchResult'
import { addToSearchHistory } from 'uniswap/src/features/search/searchHistorySlice'
import { MobileEventName } from 'uniswap/src/features/telemetry/constants'
import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send'
import { TestID } from 'uniswap/src/test/fixtures/testIDs'
import { MobileScreens } from 'uniswap/src/types/screens/mobile'
import { NFTViewer } from 'wallet/src/features/images/NFTViewer'
type NFTCollectionItemProps = {
collection: NFTCollectionSearchResult
searchContext?: SearchContext
}
export function SearchNFTCollectionItem({ collection, searchContext }: NFTCollectionItemProps): JSX.Element {
const { name, address, chainId, isVerified, imageUrl } = collection
const dispatch = useDispatch()
const navigation = useAppStackNavigation()
const onPress = (): void => {
navigation.navigate(MobileScreens.NFTCollection, {
collectionAddress: address,
})
if (searchContext) {
sendAnalyticsEvent(MobileEventName.ExploreSearchResultClicked, {
query: searchContext.query,
name,
chain: chainId,
address,
type: 'collection',
suggestion_count: searchContext.suggestionCount,
position: searchContext.position,
isHistory: searchContext.isHistory,
})
}
dispatch(
addToSearchHistory({
searchResult: {
type: SearchResultType.NFTCollection,
chainId,
address,
name,
imageUrl,
isVerified,
},
}),
)
}
return (
<TouchableArea testID={TestID.SearchNFTCollectionItem} onPress={onPress}>
<Flex row alignItems="center" gap="$spacing8" justifyContent="flex-start" px={SEARCH_ITEM_PX} py={SEARCH_ITEM_PY}>
<Flex
centered
borderRadius="$roundedFull"
height={SEARCH_ITEM_ICON_SIZE}
mr="$spacing4"
overflow="hidden"
width={SEARCH_ITEM_ICON_SIZE}
>
{imageUrl ? (
<NFTViewer uri={imageUrl} />
) : (
<Text color="$neutral1" numberOfLines={1} textAlign="center">
{name.slice(0, 1)}
</Text>
)}
</Flex>
<Flex shrink>
<Text color="$neutral1" numberOfLines={1} variant="body1">
{name}
</Text>
</Flex>
<Flex grow alignItems="flex-start" width="$spacing36">
{isVerified ? <Verified color="$accent1" size="$icon.16" /> : null}
</Flex>
</Flex>
</TouchableArea>
)
}
import { default as React } from 'react'
import ContextMenu from 'react-native-context-menu-view'
import { useDispatch } from 'react-redux'
import { useTokenDetailsNavigation } from 'src/components/TokenDetails/hooks'
import { useExploreTokenContextMenu } from 'src/components/explore/hooks'
import { SEARCH_ITEM_ICON_SIZE, SEARCH_ITEM_PX, SEARCH_ITEM_PY } from 'src/components/explore/search/constants'
import { disableOnPress } from 'src/utils/disableOnPress'
import { Flex, Text, TouchableArea } from 'ui/src'
import { TokenLogo } from 'uniswap/src/components/CurrencyLogo/TokenLogo'
import WarningIcon from 'uniswap/src/components/warnings/WarningIcon'
import { getWarningIconColors } from 'uniswap/src/components/warnings/utils'
import { UniverseChainId } from 'uniswap/src/features/chains/types'
import { SearchContext } from 'uniswap/src/features/search/SearchModal/analytics/SearchContext'
import { SearchResultType, TokenSearchResult } from 'uniswap/src/features/search/SearchResult'
import { addToSearchHistory } from 'uniswap/src/features/search/searchHistorySlice'
import { MobileEventName, SectionName } from 'uniswap/src/features/telemetry/constants'
import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send'
import { getTokenWarningSeverity } from 'uniswap/src/features/tokens/safetyUtils'
import { useCurrencyInfo } from 'uniswap/src/features/tokens/useCurrencyInfo'
import { TestID } from 'uniswap/src/test/fixtures/testIDs'
import { buildCurrencyId, buildNativeCurrencyId } from 'uniswap/src/utils/currencyId'
import { shortenAddress } from 'utilities/src/addresses'
type SearchTokenItemProps = {
token: TokenSearchResult
searchContext?: SearchContext
}
export function SearchTokenItem({ token, searchContext }: SearchTokenItemProps): JSX.Element {
const dispatch = useDispatch()
const tokenDetailsNavigation = useTokenDetailsNavigation()
const { chainId, address, name, symbol, logoUrl, safetyInfo, feeData } = token
const currencyId = address ? buildCurrencyId(chainId, address) : buildNativeCurrencyId(chainId as UniverseChainId)
const currencyInfo = useCurrencyInfo(currencyId)
const severity = getTokenWarningSeverity(currencyInfo)
// 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)
tokenDetailsNavigation.navigate(currencyId)
if (searchContext) {
sendAnalyticsEvent(MobileEventName.ExploreSearchResultClicked, {
query: searchContext.query,
name: name ?? '',
chain: token.chainId,
address: address ?? '',
type: 'token',
suggestion_count: searchContext.suggestionCount,
position: searchContext.position,
isHistory: searchContext.isHistory,
})
}
dispatch(
addToSearchHistory({
searchResult: {
type: SearchResultType.Token,
chainId,
address,
name,
symbol,
logoUrl,
safetyInfo,
feeData,
},
}),
)
}
const { menuActions, onContextMenuPress } = useExploreTokenContextMenu({
chainId: chainId as UniverseChainId,
currencyId,
analyticsSection: SectionName.ExploreSearch,
})
return (
<ContextMenu actions={menuActions} onPress={onContextMenuPress}>
<TouchableArea
testID={`${TestID.SearchTokenItem}-${name}-${chainId}`}
onLongPress={disableOnPress}
onPress={onPress}
>
<Flex row alignItems="center" gap="$spacing12" px={SEARCH_ITEM_PX} py={SEARCH_ITEM_PY}>
<TokenLogo
chainId={chainId}
name={name}
symbol={symbol}
url={logoUrl ?? undefined}
size={SEARCH_ITEM_ICON_SIZE}
/>
<Flex shrink alignItems="flex-start">
<Flex centered row gap="$spacing8">
<Flex shrink>
<Text color="$neutral1" numberOfLines={1} variant="subheading1">
{name}
</Text>
</Flex>
{warningIconColor && (
<WarningIcon severity={severity} size="$icon.16" strokeColorOverride={warningIconColor} />
)}
</Flex>
<Flex centered row gap="$spacing8">
<Text color="$neutral2" numberOfLines={1} variant="body2">
{symbol}
</Text>
{address && (
<Flex shrink>
<Text color="$neutral3" numberOfLines={1} variant="body3">
{shortenAddress(address)}
</Text>
</Flex>
)}
</Flex>
</Flex>
</Flex>
</TouchableArea>
</ContextMenu>
)
}
import React from 'react'
import { SEARCH_ITEM_ICON_SIZE, SEARCH_ITEM_PX, SEARCH_ITEM_PY } from 'src/components/explore/search/constants'
import { SearchWalletItemBase } from 'src/components/explore/search/items/SearchWalletItemBase'
import { Flex, Text } from 'ui/src'
import { AccountIcon } from 'uniswap/src/features/accounts/AccountIcon'
import { DisplayNameType } from 'uniswap/src/features/accounts/types'
import { useAvatar } from 'uniswap/src/features/address/avatar'
import { SearchContext } from 'uniswap/src/features/search/SearchModal/analytics/SearchContext'
import { UnitagSearchResult } from 'uniswap/src/features/search/SearchResult'
import { sanitizeAddressText } from 'uniswap/src/utils/addresses'
import { shortenAddress } from 'utilities/src/addresses'
import { DisplayNameText } from 'wallet/src/components/accounts/DisplayNameText'
type SearchUnitagItemProps = {
searchResult: UnitagSearchResult
searchContext?: SearchContext
}
export function SearchUnitagItem({ searchResult, searchContext }: SearchUnitagItemProps): JSX.Element {
const { address, unitag } = searchResult
const { avatar } = useAvatar(address)
const displayName = { name: unitag, type: DisplayNameType.Unitag }
return (
<SearchWalletItemBase searchContext={searchContext} searchResult={searchResult}>
<Flex row alignItems="center" gap="$spacing12" px={SEARCH_ITEM_PX} py={SEARCH_ITEM_PY}>
<AccountIcon address={address} avatarUri={avatar} size={SEARCH_ITEM_ICON_SIZE} />
<Flex alignItems="flex-start" justifyContent="center">
<DisplayNameText includeUnitagSuffix displayName={displayName} textProps={{ variant: 'body1' }} />
<Text color="$neutral2" variant="body2">
{sanitizeAddressText(shortenAddress(address))}
</Text>
</Flex>
</Flex>
</SearchWalletItemBase>
)
}
import React from 'react'
import { SEARCH_ITEM_ICON_SIZE, SEARCH_ITEM_PX, SEARCH_ITEM_PY } from 'src/components/explore/search/constants'
import { SearchWalletItemBase } from 'src/components/explore/search/items/SearchWalletItemBase'
import { Flex, Text } from 'ui/src'
import { AccountIcon } from 'uniswap/src/features/accounts/AccountIcon'
import { useENSAvatar, useENSName } from 'uniswap/src/features/ens/api'
import { SearchContext } from 'uniswap/src/features/search/SearchModal/analytics/SearchContext'
import { WalletByAddressSearchResult } from 'uniswap/src/features/search/SearchResult'
import { sanitizeAddressText } from 'uniswap/src/utils/addresses'
import { shortenAddress } from 'utilities/src/addresses'
type SearchWalletByAddressItemProps = {
searchResult: WalletByAddressSearchResult
searchContext?: SearchContext
}
export function SearchWalletByAddressItem({
searchResult,
searchContext,
}: SearchWalletByAddressItemProps): JSX.Element {
const { address } = searchResult
const formattedAddress = sanitizeAddressText(shortenAddress(address))
const { data: ensName } = useENSName(address)
const { data: avatar } = useENSAvatar(address)
return (
<SearchWalletItemBase searchContext={searchContext} searchResult={searchResult}>
<Flex row alignItems="center" gap="$spacing12" px={SEARCH_ITEM_PX} py={SEARCH_ITEM_PY}>
<AccountIcon address={address} avatarUri={avatar} size={SEARCH_ITEM_ICON_SIZE} />
<Flex shrink>
<Text ellipsizeMode="tail" numberOfLines={1} testID={`address-display/name/${ensName}`} variant="body1">
{ensName || formattedAddress}
</Text>
{ensName ? (
<Text color="$neutral2" ellipsizeMode="tail" numberOfLines={1} variant="subheading2">
{formattedAddress}
</Text>
) : null}
</Flex>
</Flex>
</SearchWalletItemBase>
)
}
import React, { PropsWithChildren, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import ContextMenu from 'react-native-context-menu-view'
import { useDispatch, useSelector } from 'react-redux'
import { useEagerExternalProfileNavigation } from 'src/app/navigation/hooks'
import { disableOnPress } from 'src/utils/disableOnPress'
import { TouchableArea } from 'ui/src'
import { selectWatchedAddressSet } from 'uniswap/src/features/favorites/selectors'
import { useToggleWatchedWalletCallback } from 'uniswap/src/features/favorites/useToggleWatchedWalletCallback'
import { SearchContext } from 'uniswap/src/features/search/SearchModal/analytics/SearchContext'
import { SearchResultType, WalletSearchResult, extractDomain } from 'uniswap/src/features/search/SearchResult'
import { addToSearchHistory } from 'uniswap/src/features/search/searchHistorySlice'
import { MobileEventName } from 'uniswap/src/features/telemetry/constants'
import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send'
type SearchWalletItemBaseProps = {
searchResult: WalletSearchResult
searchContext?: SearchContext
}
export function SearchWalletItemBase({
children,
searchResult,
searchContext,
}: PropsWithChildren<SearchWalletItemBaseProps>): JSX.Element {
const { t } = useTranslation()
const dispatch = useDispatch()
const { preload, navigate } = useEagerExternalProfileNavigation()
const { address, type } = searchResult
const isFavorited = useSelector(selectWatchedAddressSet).has(address)
const onPress = (): void => {
navigate(address)
if (searchContext) {
const walletName =
type === SearchResultType.Unitag
? searchResult.unitag
: type === SearchResultType.ENSAddress
? searchResult.ensName
: undefined
sendAnalyticsEvent(MobileEventName.ExploreSearchResultClicked, {
query: searchContext.query,
name: walletName,
address,
type: 'address',
domain: walletName && type !== SearchResultType.WalletByAddress ? extractDomain(walletName, type) : undefined,
suggestion_count: searchContext.suggestionCount,
position: searchContext.position,
isHistory: searchContext.isHistory,
})
}
if (type === SearchResultType.ENSAddress) {
dispatch(
addToSearchHistory({
searchResult: {
...searchResult,
primaryENSName: searchResult.primaryENSName,
},
}),
)
} else {
dispatch(
addToSearchHistory({
searchResult,
}),
)
}
}
const toggleFavoriteWallet = useToggleWatchedWalletCallback(address)
const menuActions = useMemo(() => {
return isFavorited
? [{ title: t('explore.wallets.favorite.action.remove'), systemIcon: 'heart.fill' }]
: [{ title: t('explore.wallets.favorite.action.add'), systemIcon: 'heart' }]
}, [isFavorited, t])
return (
<ContextMenu actions={menuActions} onPress={toggleFavoriteWallet}>
<TouchableArea
testID={`wallet-item-${type}-${address}`}
onLongPress={disableOnPress}
onPress={onPress}
onPressIn={async (): Promise<void> => {
await preload(address)
}}
>
{children}
</TouchableArea>
</ContextMenu>
)
}
import { SearchResult } from 'uniswap/src/features/search/SearchResult'
export type SearchHeaderKey = 'header'
export type SearchHeader = { type: SearchHeaderKey; title: string; icon?: JSX.Element }
export type SearchResultOrHeader = SearchResult | SearchHeader
import { faker } from '@faker-js/faker'
import {
formatNFTCollectionSearchResults,
formatTokenSearchResults,
gqlNFTToNFTCollectionSearchResult,
} from 'src/components/explore/search/utils'
import { Chain } 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'
describe(formatTokenSearchResults, () => {
it('returns undefined if there is no data', () => {
expect(formatTokenSearchResults(undefined, null)).toEqual(undefined)
})
it('filters out duplicate results', () => {
const searchToken = token()
const data = createArray(2, () => searchToken)
const result = formatTokenSearchResults(data, null)
expect(result).toHaveLength(1)
expect(result?.[0]?.address).toEqual(data[0].address)
})
it('uses tokens with highest volume for tokens with the same project id', () => {
const changedAddress = faker.finance.ethereumAddress()
const data = [
// Tokens with the same address and chain will have the same project id
ethToken({
market: tokenMarket({ volume: amount({ value: 10 }) }),
}),
ethToken({
address: changedAddress,
market: tokenMarket({ volume: amount({ value: 100 }) }),
}),
ethToken({
market: tokenMarket({ volume: amount({ value: 20 }) }),
}),
]
const result = formatTokenSearchResults(data, null)
// Filters out the first token (both tokens share the same project id)
expect(result).toHaveLength(1)
// Uses the token with highest volume
expect(result?.[0]?.address).toEqual(changedAddress)
})
it('properly formats token search result', () => {
const searchToken = token()
const data = [searchToken]
const result = formatTokenSearchResults(data, null)
expect(result).toHaveLength(1)
expect(result?.[0]?.type).toEqual(SearchResultType.Token)
expect(result?.[0]?.chainId).toEqual(fromGraphQLChain(searchToken.chain))
expect(result?.[0]?.address).toEqual(searchToken.address)
expect(result?.[0]?.name).toEqual(searchToken.name)
expect(result?.[0]?.symbol).toEqual(searchToken.symbol)
expect(result?.[0]?.logoUrl).toEqual(searchToken.project.logoUrl)
expect(result?.[0]?.feeData).toEqual(searchToken.feeData)
expect(result?.[0]?.safetyInfo).toEqual(
getCurrencySafetyInfo(searchToken.project.safetyLevel, searchToken.protectionInfo),
)
})
describe(gqlNFTToNFTCollectionSearchResult, () => {
const collection = nftCollection({
nftContracts: [nftContract({ chain: Chain.Ethereum })],
})
it('returns null if required data is missing', () => {
expect(gqlNFTToNFTCollectionSearchResult({ ...collection, name: undefined })).toEqual(null)
expect(gqlNFTToNFTCollectionSearchResult({ ...collection, nftContracts: undefined })).toEqual(null)
expect(gqlNFTToNFTCollectionSearchResult({ ...collection, nftContracts: [] })).toEqual(null)
})
it('properly formats NFT collection search result', () => {
const result = gqlNFTToNFTCollectionSearchResult(collection)
expect(result?.type).toEqual(SearchResultType.NFTCollection)
expect(result?.chainId).toEqual(fromGraphQLChain(Chain.Ethereum))
expect(result?.address).toEqual(collection.nftContracts[0]?.address)
expect(result?.name).toEqual(collection.name)
expect(result?.imageUrl).toEqual(collection.image.url)
expect(result?.isVerified).toEqual(collection.isVerified)
})
})
describe(formatNFTCollectionSearchResults, () => {
it('returns undefined if there is no data', () => {
expect(formatNFTCollectionSearchResults(undefined, null)).toEqual(undefined)
})
it('filters out nfts that cannot be formatted', () => {
const topNFTCollections = createArray(2, nftCollection)
const nftSearchResult = {
edges: [...topNFTCollections.map((nft) => ({ node: nft })), { node: nftCollection({ name: undefined }) }],
}
const result = formatNFTCollectionSearchResults(nftSearchResult, null)
expect(result).toHaveLength(2)
expect(result?.[0]?.address).toEqual(topNFTCollections[0].nftContracts[0]?.address)
expect(result?.[1]?.address).toEqual(topNFTCollections[1].nftContracts[0]?.address)
})
})
})
import { SEARCH_RESULT_HEADER_KEY } from 'src/components/explore/search/constants'
import { SearchResultOrHeader } from 'src/components/explore/search/types'
import { Chain, ExploreSearchQuery } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks'
import { UniverseChainId } from 'uniswap/src/features/chains/types'
import { fromGraphQLChain } from 'uniswap/src/features/chains/utils'
import { getCurrencySafetyInfo } from 'uniswap/src/features/dataApi/utils'
import {
NFTCollectionSearchResult,
SearchResultType,
TokenSearchResult,
} from 'uniswap/src/features/search/SearchResult'
import { searchResultId } from 'uniswap/src/features/search/searchHistorySlice'
const MAX_TOKEN_RESULTS_COUNT = 8
type ExploreSearchResult = NonNullable<ExploreSearchQuery>
// Formats the tokens portion of explore search results into sorted array of TokenSearchResult
export function formatTokenSearchResults(
data: ExploreSearchResult['searchTokens'],
selectedChain: UniverseChainId | null,
): TokenSearchResult[] | undefined {
if (!data) {
return undefined
}
// Prevent showing "duplicate" token search results for tokens that are on multiple chains
// and share the same TokenProject id. Only show the token that has the highest 1Y Uniswap trading volume
// ex. UNI on Mainnet, Arbitrum, Optimism -> only show UNI on Mainnet b/c it has highest 1Y volume
const tokenResultsMap = data.reduce<Record<string, TokenSearchResult & { volume1D: number }>>((tokensMap, token) => {
if (!token) {
return tokensMap
}
const { name, chain, address, symbol, project, market, protectionInfo, feeData } = token
const chainId = fromGraphQLChain(chain)
const shoulderFilterByChain = !!selectedChain
const chainMismatch = shoulderFilterByChain && selectedChain !== chainId
if (!chainId || !project || chainMismatch) {
return tokensMap
}
const { safetyLevel, logoUrl } = project
const tokenResult: TokenSearchResult & { volume1D: number } = {
type: SearchResultType.Token,
chainId,
address: address ?? null,
name: name ?? null,
symbol: symbol ?? '',
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
const currentTokenResult = tokensMap[project.id]
if (!currentTokenResult || tokenResult.volume1D > currentTokenResult.volume1D) {
tokensMap[project.id] = tokenResult
}
return tokensMap
}, {})
return Object.values(tokenResultsMap).slice(0, MAX_TOKEN_RESULTS_COUNT)
}
export function formatNFTCollectionSearchResults(
data: ExploreSearchResult['nftCollections'],
selectedChain: UniverseChainId | null,
): NFTCollectionSearchResult[] | undefined {
if (!data) {
return undefined
}
return data.edges.reduce<NFTCollectionSearchResult[]>((accum, { node }) => {
const formatted = gqlNFTToNFTCollectionSearchResult(node)
const chainMismatch = selectedChain && formatted && formatted.chainId !== selectedChain
if (formatted && !chainMismatch) {
accum.push(formatted)
}
return accum
}, [])
}
type NFTCollectionItemResult = NonNullable<
NonNullable<NonNullable<NonNullable<ExploreSearchResult['nftCollections']>>['edges']>[0]
>['node']
export const gqlNFTToNFTCollectionSearchResult = (node: NFTCollectionItemResult): NFTCollectionSearchResult | null => {
const contract = node.nftContracts?.[0]
// Only show NFT results that have fully populated results
const chainId = fromGraphQLChain(contract?.chain ?? Chain.Ethereum)
if (node.name && contract?.address && chainId) {
return {
type: SearchResultType.NFTCollection,
chainId,
address: contract.address,
name: node.name,
imageUrl: node.image?.url ?? null,
isVerified: Boolean(node.isVerified),
}
}
return null
}
export const getSearchResultId = (searchResult: SearchResultOrHeader): string => {
if (searchResult.type === SEARCH_RESULT_HEADER_KEY) {
return searchResult.title
}
// Unique ID for each search result
return searchResultId(searchResult)
}
......@@ -46,10 +46,6 @@ export function CloudBackupProcessingAnimation({
const account = activeAccount || onboardingContextAccount
// Compute backup status at component level to avoid stale closure - ensures fresh evaluation on each render
// when onboardingContextAccount updates with new backup state
const accountHasCloudBackup = hasBackup(BackupType.Cloud, account)
if (!account) {
throw Error('No account available for backup')
}
......@@ -60,14 +56,14 @@ export function CloudBackupProcessingAnimation({
// Handle finished backing up to Cloud
useEffect(() => {
if (accountHasCloudBackup) {
if (hasBackup(BackupType.Cloud, account)) {
doneProcessing()
// Show success state for 1s before navigating
const timer = setTimeout(onBackupComplete, ONE_SECOND_MS)
return () => clearTimeout(timer)
}
return undefined
}, [accountHasCloudBackup, onBackupComplete])
}, [account, onBackupComplete])
// Handle backup to Cloud when screen appears
const backup = useCallback(async () => {
......
......@@ -2,6 +2,7 @@ import { useScrollToTop } from '@react-navigation/native'
import { SharedEventName } from '@uniswap/analytics-events'
import React, { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { TextInput } from 'react-native'
import { FlatList } from 'react-native-gesture-handler'
import { useAnimatedRef } from 'react-native-reanimated'
import { ExploreSections } from 'src/components/explore/ExploreSections'
......@@ -32,6 +33,7 @@ export function ExploreScreen(): JSX.Element {
useScrollToTop(listRef)
const [isSearchMode, setIsSearchMode] = useState<boolean>(false)
const textInputRef = useRef<TextInput>(null)
// TODO(WALL-5482): investigate list rendering performance/scrolling issue
const canRenderList = useRenderNextFrame(!isSearchMode)
......@@ -40,6 +42,7 @@ export function ExploreScreen(): JSX.Element {
const onSearchChangeText = (newSearchFilter: string): void => {
onChangeText(newSearchFilter)
textInputRef.current?.setNativeProps({ text: newSearchFilter })
}
const onSearchFocus = (): void => {
......@@ -59,6 +62,7 @@ export function ExploreScreen(): JSX.Element {
<HandleBar backgroundColor="none" />
<Flex p="$spacing16">
<SearchTextInput
ref={textInputRef}
cancelBehaviorType={CancelBehaviorType.BackChevron}
endAdornment={
isSearchMode ? (
......@@ -92,8 +96,8 @@ export function ExploreScreen(): JSX.Element {
<ExploreScreenSearchResultsList
searchQuery={searchFilter ?? ''}
parsedSearchQuery={parsedSearchFilter}
chainFilter={chainFilter}
parsedChainFilter={parsedChainFilter}
chainFilter={chainFilter ?? parsedChainFilter}
textInputRef={textInputRef}
/>
) : (
isSheetReady && canRenderList && <ExploreSections listRef={listRef} />
......
......@@ -40,8 +40,10 @@ const ANDROID_E2E_WORKAROUND = config.isE2ETest && isAndroid
export function RestoreCloudBackupLoadingScreen({ navigation, route: { params } }: Props): JSX.Element {
const { t } = useTranslation()
const dispatch = useDispatch()
const entryPoint = params.entryPoint
const importType = params.importType
const isRestoringMnemonic = params.importType === ImportType.RestoreMnemonic
const isRestoringMnemonic = importType === ImportType.RestoreMnemonic
// inits with null before fetchCloudStorageBackups starts fetching
const [isLoading, setIsLoading] = useState<boolean | null>(null)
const [isError, setIsError] = useState(false)
......@@ -130,15 +132,17 @@ export function RestoreCloudBackupLoadingScreen({ navigation, route: { params }
}
if (backups.length === 1 && backups[0]) {
navigation.replace(OnboardingScreens.RestoreCloudBackupPassword, {
...params,
importType,
entryPoint,
mnemonicId: backups[0].mnemonicId,
})
} else {
navigation.replace(OnboardingScreens.RestoreCloudBackup, {
...params,
importType,
entryPoint,
})
}
}, [backups, isLoading, navigation, params])
}, [backups, entryPoint, importType, isLoading, navigation])
if (isError) {
return (
......@@ -158,8 +162,9 @@ export function RestoreCloudBackupLoadingScreen({ navigation, route: { params }
if (isLoading === false && backups.length === 0) {
if (isRestoringMnemonic) {
navigation.replace(OnboardingScreens.SeedPhraseInput, {
...params,
showAsCloudBackupFallback: true,
importType,
entryPoint,
})
} else {
return (
......
......@@ -39,7 +39,7 @@ export function RestoreMethodScreen({ navigation, route: { params } }: Props): J
const handleOnPress = async (nav: OnboardingScreens, importType: ImportType): Promise<void> => {
navigation.navigate({
name: nav,
params: { ...params, importType, entryPoint },
params: { importType, entryPoint },
merge: true,
})
}
......
......@@ -18,8 +18,7 @@ import { Button, Flex, MobileDeviceHeight, Text, TouchableArea, useIsShortMobile
import { PapersText, QuestionInCircleFilled } from 'ui/src/components/icons'
import { uniswapUrls } from 'uniswap/src/constants/urls'
import Trace from 'uniswap/src/features/telemetry/Trace'
import { ElementName, MobileEventName } from 'uniswap/src/features/telemetry/constants'
import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send'
import { ElementName } from 'uniswap/src/features/telemetry/constants'
import { TestID } from 'uniswap/src/test/fixtures/testIDs'
import { ImportType } from 'uniswap/src/types/onboarding'
import { OnboardingScreens } from 'uniswap/src/types/screens/mobile'
......@@ -78,7 +77,6 @@ export function SeedPhraseInputScreen({ navigation, route: { params } }: SeedPhr
)
const handleSubmitError: NativeSeedPhraseInputProps['onSubmitError'] = useCallback(() => {
sendAnalyticsEvent(MobileEventName.SeedPhraseInputSubmitError)
setIsSubmitEnabled(true)
}, [setIsSubmitEnabled])
......
import { NativeStackScreenProps } from '@react-navigation/native-stack'
import React, { ComponentProps, useCallback } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { ScrollView } from 'react-native'
import { ScrollView, StyleSheet } from 'react-native'
import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated'
import { navigate } from 'src/app/navigation/rootNavigation'
import { OnboardingStackParamList } from 'src/app/navigation/types'
import { OnboardingScreen } from 'src/features/onboarding/OnboardingScreen'
import { Button, Flex, Loader, Text, TouchableArea, useLayoutAnimationOnChange } from 'ui/src'
import {
Button,
Flex,
LinearGradient,
Loader,
Text,
TouchableArea,
useLayoutAnimationOnChange,
useSporeColors,
} from 'ui/src'
import { WalletFilled } from 'ui/src/components/icons'
import { spacing } from 'ui/src/theme'
import { opacify, spacing } from 'ui/src/theme'
import { BaseCard } from 'uniswap/src/components/BaseCard/BaseCard'
import { FeatureFlags } from 'uniswap/src/features/gating/flags'
import { useFeatureFlag } from 'uniswap/src/features/gating/hooks'
......@@ -21,6 +30,7 @@ import WalletPreviewCard from 'wallet/src/components/WalletPreviewCard/WalletPre
import { useOnboardingContext } from 'wallet/src/features/onboarding/OnboardingContext'
import { useImportableAccounts } from 'wallet/src/features/onboarding/hooks/useImportableAccounts'
import { useSelectAccounts } from 'wallet/src/features/onboarding/hooks/useSelectAccounts'
import { useAnyAccountEligibleForDelegation } from 'wallet/src/features/smartWallet/hooks/useAnyAccountEligibleForDelegation'
const ANIMATION_DURATION = 300
......@@ -30,6 +40,7 @@ export function SelectWalletScreen({ navigation, route: { params } }: Props): JS
const { t } = useTranslation()
const { selectImportedAccounts, getImportedAccountsAddresses } = useOnboardingContext()
const importedAddresses = getImportedAccountsAddresses()
const colors = useSporeColors()
const {
importableAccounts,
......@@ -67,9 +78,13 @@ export function SelectWalletScreen({ navigation, route: { params } }: Props): JS
const highlightComponent = <CustomHighlightText />
const isContinueButtonDisabled = isLoading || !!showError || selectedAddresses.length === 0
const { eligible: isAnyAccountEligibleForDelegation, loading: isDelegationChecksLoading } =
useAnyAccountEligibleForDelegation(importableAccounts)
const showSmartWalletDisclaimer = smartWalletEnabled && !isContinueButtonDisabled
const isContinueButtonDisabled =
isLoading || !!showError || selectedAddresses.length === 0 || (isDelegationChecksLoading && smartWalletEnabled)
const showSmartWalletDisclaimer = smartWalletEnabled && !isContinueButtonDisabled && isAnyAccountEligibleForDelegation
const opacityStyle = useAnimatedStyle(
() => ({
......@@ -93,44 +108,59 @@ export function SelectWalletScreen({ navigation, route: { params } }: Props): JS
title={t('account.wallet.select.error')}
onRetry={refetchAccounts}
/>
) : isLoading ? (
) : isLoading || isDelegationChecksLoading ? (
<Flex grow justifyContent="space-between" px="$spacing16">
<Loader.Wallets repeat={5} />
</Flex>
) : (
<ScrollView testID={TestID.SelectWalletScreenLoaded}>
<Flex height="$spacing12" />
<Flex gap="$gap12">
{importableAccounts?.map((account, i) => {
const { address, balance } = account
// prevents flickering and incorrect width calculation for long wallet names on Android
// it's not possible to deselect last wallet
if (selectedAddresses.length === 0) {
return null
}
return (
<Flex key={address} px="$spacing16">
<WalletPreviewCard
key={address}
address={address}
balance={balance}
hideSelectionCircle={isOnlyOneAccount}
name={ElementName.WalletCard}
selected={selectedAddresses.includes(address)}
testID={`${TestID.WalletCard}-${i + 1}`}
onSelect={toggleAddressSelection}
/>
</Flex>
)
})}
</Flex>
</ScrollView>
<Flex flexGrow={1} flexShrink={1}>
<LinearGradient
colors={[colors.surface1.val, opacify(0, colors.surface1.val)]}
end={{ x: 0, y: 1 }}
start={{ x: 0, y: 0 }}
style={ListSheet.topGradient}
/>
<ScrollView testID={TestID.SelectWalletScreenLoaded}>
<Flex height="$spacing12" />
<Flex gap="$gap12">
{importableAccounts?.map((account, i) => {
const { address, balance } = account
// prevents flickering and incorrect width calculation for long wallet names on Android
// it's not possible to deselect last wallet
if (selectedAddresses.length === 0) {
return null
}
return (
<Flex key={address} px="$spacing16">
<WalletPreviewCard
key={address}
address={address}
balance={balance}
hideSelectionCircle={isOnlyOneAccount}
name={ElementName.WalletCard}
selected={selectedAddresses.includes(address)}
testID={`${TestID.WalletCard}-${i + 1}`}
onSelect={toggleAddressSelection}
/>
</Flex>
)
})}
</Flex>
</ScrollView>
<LinearGradient
colors={[opacify(0, colors.surface1.val), colors.surface1.val]}
end={{ x: 0, y: 1 }}
start={{ x: 0, y: 0 }}
style={ListSheet.bottomGradient}
/>
</Flex>
)}
<Animated.View
style={[
opacityStyle,
{
marginBottom: spacing.spacing16,
marginTop: spacing.spacing16,
marginHorizontal: spacing.spacing24,
},
]}
......@@ -155,7 +185,7 @@ export function SelectWalletScreen({ navigation, route: { params } }: Props): JS
<Flex opacity={showError ? 0 : 1} px="$spacing16">
<Flex row>
<Button
isDisabled={isContinueButtonDisabled}
isDisabled={isLoading || !!showError || selectedAddresses.length === 0}
variant="branded"
size="large"
testID={TestID.Continue}
......@@ -170,6 +200,24 @@ export function SelectWalletScreen({ navigation, route: { params } }: Props): JS
)
}
const ListSheet = StyleSheet.create({
bottomGradient: {
bottom: 0,
height: spacing.spacing16,
left: 0,
position: 'absolute',
width: '100%',
},
topGradient: {
height: spacing.spacing16,
left: 0,
position: 'absolute',
top: 0,
width: '100%',
zIndex: 1,
},
})
function CustomHighlightText(props: ComponentProps<typeof Text>): JSX.Element {
return <Text variant="buttonLabel4" color="$neutral1" {...props} />
}
......@@ -38,7 +38,6 @@ export function onRestoreComplete({
sendAnalyticsEvent(MobileEventName.RestoreSuccess, {
is_restoring_mnemonic: isRestoringMnemonic,
import_type: params.importType,
restore_type: params.restoreType,
screen,
})
}
......@@ -91,22 +91,18 @@ export function ViewPrivateKeysScreen({ navigation, route }: Props): JSX.Element
<BulletRow Icon={Laptop} description={t('privateKeys.export.modal.speedbump.bullet3')} />
</Flex>
<Flex row py="$spacing24" gap="$gap8">
<Trace logPress element={ElementName.Cancel}>
<Button variant="default" emphasis="secondary" size="medium" onPress={navigation.goBack}>
{t('common.button.close')}
</Button>
</Trace>
<Trace logPress element={ElementName.Continue}>
<Button
variant="branded"
emphasis="primary"
size="medium"
testID={TestID.Continue}
onPress={onBiometricContinue}
>
{t('common.button.continue')}
</Button>
</Trace>
<Button variant="default" emphasis="secondary" size="medium" onPress={navigation.goBack}>
{t('common.button.close')}
</Button>
<Button
variant="branded"
emphasis="primary"
size="medium"
testID={TestID.Continue}
onPress={onBiometricContinue}
>
{t('common.button.continue')}
</Button>
</Flex>
</Flex>
)
......
......@@ -48,6 +48,8 @@ ignores: [
'detect-package-manager',
'eslint-plugin-storybook',
'prop-types',
## Testing
'@types/testing-library__cypress',
## i18n
'dotenv-cli',
'@crowdin/cli',
......@@ -68,7 +70,6 @@ ignores: [
'constants',
'dev',
'featureFlags',
'features',
'hooks',
'lib',
'locales',
......
......@@ -12,6 +12,9 @@ module.exports = {
},
},
rules: {
// TODO: had to add this rule to avoid errors on monorepo migration that didnt happen in interface
'cypress/unsafe-to-chain-command': 'off',
// let prettier do things:
semi: 0,
quotes: 0,
......@@ -24,6 +27,7 @@ module.exports = {
{
files: [
'src/index.tsx',
'cypress/utils/index.ts',
'src/tracing/index.ts',
'src/state/index.ts',
'src/state/explore/index.tsx',
......
......@@ -30,8 +30,6 @@ bundlemeta.json
# misc
.DS_Store
tsconfig.tsbuildinfo
!.env
.env.local
......@@ -54,6 +52,10 @@ notes.txt
package-lock.json
cypress/downloads
cypress/videos
cypress/screenshots
.vercel
.wrangler
......
import { defineConfig } from 'cypress'
import { setupHardhatEvents } from 'cypress-hardhat'
const TIMEOUT = 24000 // 2x average block time
export default defineConfig({
projectId: 'fabfoi',
defaultCommandTimeout: TIMEOUT,
requestTimeout: TIMEOUT,
chromeWebSecurity: false,
experimentalMemoryManagement: true, // better memory management, see https://github.com/cypress-io/cypress/pull/25462
retries: { runMode: process.env.CYPRESS_RETRIES ? +process.env.CYPRESS_RETRIES : 1 },
video: false, // GH provides 2 CPUs, and cypress video eats one up, see https://github.com/cypress-io/cypress/issues/20468#issuecomment-1307608025
e2e: {
async setupNodeEvents(on, config) {
await setupHardhatEvents(on, config)
return config
},
baseUrl: 'http://localhost:3000',
specPattern: 'cypress/{e2e,staging}/**/*.test.ts',
},
})
This diff is collapsed.
// see https://github.com/Uniswap/interface/pull/4115
describe('Link', () => {
it('should update route', () => {
cy.viewport(2000, 1600)
cy.visit('/swap')
cy.contains('Pool').click()
cy.get('[data-cy="join-pool-button"]').should('exist')
})
})
import { BigNumber } from '@ethersproject/bignumber'
import { MaxUint160, MaxUint256 } from '@uniswap/permit2-sdk'
import { CurrencyAmount, Token } from '@uniswap/sdk-core'
import { DAI, USDT } from 'uniswap/src/constants/tokens'
import { HARDHAT_TIMEOUT, setupHardhat } from '../utils'
/** Initiates a swap. */
function initiateSwap(swapButtonText?: string) {
// The swap button is re-rendered once enabled, so we must wait until the original button is not disabled to re-select the appropriate button.
cy.get('#swap-button').should('not.be.disabled')
// Completes the swap.
cy.get('#swap-button').click()
cy.contains(swapButtonText ?? 'Confirm swap').click()
}
describe('Permit2', () => {
function setupInputs(inputToken: Token, outputToken: Token) {
// Sets up a swap between inputToken and outputToken.
cy.visit(`/swap/?inputCurrency=${inputToken.address}&outputCurrency=${outputToken.address}`)
cy.get('#swap-currency-input .token-amount-input').type('0.01')
}
/** Asserts permit2 has a max approval for spend of the input token on-chain. */
function expectTokenAllowanceForPermit2ToBeMax(inputToken: Token) {
// check token approval
cy.hardhat()
.then(({ approval, wallet }) => approval.getTokenAllowanceForPermit2({ owner: wallet, token: inputToken }))
.then((allowance) => {
Cypress.log({ name: `Token allowance: ${allowance.toString()}` })
cy.wrap(allowance).should('deep.equal', MaxUint256)
})
}
/** Asserts the universal router has a max permit2 approval for spend of the input token on-chain. */
function expectPermit2AllowanceForUniversalRouterToBeMax(inputToken: Token) {
cy.hardhat()
.then(({ approval, wallet }) => approval.getPermit2Allowance({ owner: wallet, token: inputToken }))
.then((allowance) => {
Cypress.log({ name: `Permit2 allowance: ${allowance.amount.toString()}` })
cy.wrap(allowance.amount).should('deep.equal', MaxUint160)
// Asserts that the on-chain expiration is in 30 days, within a tolerance of 40 seconds.
const THIRTY_DAYS_SECONDS = 2_592_000
const expected = Math.floor(Date.now() / 1000 + THIRTY_DAYS_SECONDS)
cy.wrap(allowance.expiration).should('be.closeTo', expected, 40)
})
}
setupHardhat(async (hardhat) => {
await hardhat.fund(hardhat.wallet, CurrencyAmount.fromRawAmount(DAI, 1e18))
await hardhat.mine()
})
describe('approval process (with intermediate screens)', () => {
// Turn off automine so that intermediate screens are available to assert on.
before(() => cy.hardhat({ automine: false }))
after(() => cy.hardhat({ automine: true }))
it('swaps after completing full permit2 approval process', () => {
setupInputs(DAI, USDT)
initiateSwap('Approve and swap')
// verify that the modal retains its state when the window loses focus
cy.window().trigger('blur')
// Verify token approval
cy.contains('Approval pending...')
cy.wait('@eth_sendRawTransaction')
cy.hardhat().then((hardhat) => hardhat.mine())
expectTokenAllowanceForPermit2ToBeMax(DAI)
// Verify permit2 approval
cy.wait('@eth_signTypedData_v4')
cy.wait('@eth_sendRawTransaction')
cy.contains('Swap pending...')
cy.hardhat().then((hardhat) => hardhat.mine())
cy.contains('Swap success!')
expectPermit2AllowanceForUniversalRouterToBeMax(DAI)
})
it('swaps with existing permit approval and missing token approval', () => {
setupInputs(DAI, USDT)
cy.hardhat().then({ timeout: HARDHAT_TIMEOUT }, async (hardhat) => {
await hardhat.approval.setPermit2Allowance({ owner: hardhat.wallet, token: DAI })
await hardhat.mine()
})
initiateSwap('Approve and swap')
// Verify token approval
cy.contains('Approval pending...')
cy.wait('@eth_sendRawTransaction')
cy.hardhat().then((hardhat) => hardhat.mine())
expectTokenAllowanceForPermit2ToBeMax(DAI)
// Verify transaction
cy.wait('@eth_sendRawTransaction')
cy.hardhat().then((hardhat) => hardhat.mine())
cy.contains('Swap success!')
})
/**
* On mainnet, you have to revoke USDT approval before increasing it.
* From the token contract:
* To change the approve amount you first have to reduce the addresses`
* allowance to zero by calling `approve(_spender, 0)` if it is not
* already 0 to mitigate the race condition described here:
* https://github.com/ethereum/EIPs/issues/20#issuecomment-263524729
*/
it('swaps USDT with existing but insufficient approval permit', () => {
cy.hardhat().then({ timeout: HARDHAT_TIMEOUT }, async (hardhat) => {
await hardhat.fund(hardhat.wallet, CurrencyAmount.fromRawAmount(USDT, 2e6))
await hardhat.approval.setTokenAllowanceForPermit2({ owner: hardhat.wallet, token: USDT }, 1e6)
await hardhat.mine()
await hardhat.approval.setPermit2Allowance({ owner: hardhat.wallet, token: USDT })
await hardhat.mine()
})
setupInputs(USDT, DAI)
cy.get('#swap-currency-input .token-amount-input').clear().type('2')
initiateSwap('Approve and swap')
// Verify allowance revocation
cy.contains('Resetting USDT limit...')
cy.wait('@eth_sendRawTransaction')
cy.hardhat().then((hardhat) => hardhat.mine())
cy.hardhat()
.then(({ approval, wallet }) => approval.getTokenAllowanceForPermit2({ owner: wallet, token: USDT }))
.should('deep.equal', BigNumber.from(0))
// Verify token approval
cy.contains('Approval pending...')
cy.wait('@eth_sendRawTransaction')
cy.hardhat().then((hardhat) => hardhat.mine())
expectTokenAllowanceForPermit2ToBeMax(USDT)
// Verify transaction
cy.wait('@eth_sendRawTransaction')
cy.hardhat().then((hardhat) => hardhat.mine())
cy.contains('Swap success!')
})
})
it('swaps when user has already approved token and permit2', () => {
cy.hardhat().then({ timeout: HARDHAT_TIMEOUT }, async (hardhat) => {
await hardhat.approval.setTokenAllowanceForPermit2({ owner: hardhat.wallet, token: DAI })
await hardhat.approval.setPermit2Allowance({ owner: hardhat.wallet, token: DAI })
})
setupInputs(DAI, USDT)
initiateSwap()
// Verify transaction
cy.contains('Swap success!')
})
it('swaps after handling user rejection of both approval and signature', () => {
setupInputs(DAI, USDT)
const USER_REJECTION = { code: 4001 }
cy.hardhat().then((hardhat) => {
// Reject token approval
const tokenApprovalStub = cy.stub(hardhat.wallet, 'sendTransaction').log(true)
tokenApprovalStub.rejects(USER_REJECTION) // rejects token approval
initiateSwap('Approve and swap')
// Verify token approval rejection
cy.wrap(tokenApprovalStub).should('be.calledOnce')
cy.contains('Approve and swap')
// Allow token approval
cy.then(() => tokenApprovalStub.restore())
// Reject permit2 approval
const permitApprovalStub = cy.stub(hardhat.provider, 'send').log(false)
permitApprovalStub.withArgs('eth_signTypedData_v4').rejects(USER_REJECTION) // rejects permit approval
permitApprovalStub.callThrough() // allows non-eth_signTypedData_v4 send calls to return non-stubbed values
cy.contains('Approve and swap').click()
// Verify token approval
cy.wait('@eth_sendRawTransaction')
cy.hardhat().then((hardhat) => hardhat.mine())
expectTokenAllowanceForPermit2ToBeMax(DAI)
// Verify permit2 approval rejection
cy.wrap(permitApprovalStub).should('be.calledWith', 'eth_signTypedData_v4')
cy.contains('Sign and swap')
// Allow permit2 approval
cy.then(() => permitApprovalStub.restore())
cy.contains('Sign and swap').click()
// Verify permit2 approval
cy.contains('Swap success!')
expectPermit2AllowanceForUniversalRouterToBeMax(DAI)
})
})
it('prompts token approval when existing approval amount is too low', () => {
setupInputs(DAI, USDT)
cy.hardhat().then({ timeout: HARDHAT_TIMEOUT }, async (hardhat) => {
await hardhat.approval.setPermit2Allowance({ owner: hardhat.wallet, token: DAI })
await hardhat.approval.setTokenAllowanceForPermit2({ owner: hardhat.wallet, token: DAI }, 1)
})
initiateSwap('Approve and swap')
// Verify token approval
expectPermit2AllowanceForUniversalRouterToBeMax(DAI)
})
it('prompts signature when existing permit approval is expired', () => {
const expiredAllowance = { expiration: Math.floor((Date.now() - 1) / 1000) }
cy.hardhat()
.then({ timeout: HARDHAT_TIMEOUT }, async (hardhat) => {
await hardhat.approval.setTokenAllowanceForPermit2({ owner: hardhat.wallet, token: DAI })
await hardhat.approval.setPermit2Allowance({ owner: hardhat.wallet, token: DAI }, expiredAllowance)
})
.then(() => {
setupInputs(DAI, USDT)
initiateSwap('Sign and swap')
})
// Verify permit2 approval
cy.wait('@eth_signTypedData_v4')
cy.contains('Swap success!')
expectPermit2AllowanceForUniversalRouterToBeMax(DAI)
})
it('prompts signature when existing permit approval amount is too low', () => {
const smallAllowance = { amount: 1 }
cy.hardhat()
.then({ timeout: HARDHAT_TIMEOUT }, async (hardhat) => {
await hardhat.approval.setTokenAllowanceForPermit2({ owner: hardhat.wallet, token: DAI })
await hardhat.approval.setPermit2Allowance({ owner: hardhat.wallet, token: DAI }, smallAllowance)
})
.then(() => {
setupInputs(DAI, USDT)
initiateSwap('Sign and swap')
})
// Verify permit2 approval
cy.wait('@eth_signTypedData_v4')
cy.contains('Swap success!')
expectPermit2AllowanceForUniversalRouterToBeMax(DAI)
})
})
describe('Pool', () => {
beforeEach(() => {
cy.visit('/pool').then(() => {
cy.wait('@eth_blockNumber')
})
})
it('add liquidity links to /add/ETH', () => {
cy.get('body').then(() => {
cy.get('#join-pool-button')
.click()
.then(() => {
cy.url().should('contain', '/add/ETH')
})
})
})
})
describe('Redirect', () => {
it('should redirect to /vote/create-proposal when visiting /create-proposal', () => {
cy.visit('/create-proposal')
cy.url().should('match', /\/vote.uniswapfoundation.org/)
})
it('should redirect to /not-found when visiting nonexist url', () => {
cy.visit('/none-exist-url')
cy.url().should('match', /\/not-found/)
})
})
describe('RedirectExplore', () => {
it('should redirect from /tokens/ to /explore', () => {
cy.visit('/tokens')
cy.url().should('match', /\/explore/)
cy.visit('/tokens/ethereum')
cy.url().should('match', /\/explore\/tokens\/ethereum/)
cy.visit('/tokens/optimism/NATIVE')
cy.url().should('match', /\/explore\/tokens\/optimism\/NATIVE/)
})
})
describe('Legacy Pool Redirects', () => {
it('should redirect /pool to /positions', () => {
cy.visit('/pool')
cy.url().should('match', /\/positions/)
})
it('should redirect /pool/:tokenId with chain param to /positions/v3/:chainName/:tokenId', () => {
cy.visit('/pool/123?chain=mainnet')
cy.url().should('match', /\/positions\/v3\/ethereum\/123/)
})
it('should redirect add v2 liquidity to positions create page', () => {
cy.visit('/add/v2/0x318400242bFdE3B20F49237a9490b8eBB6bdB761/ETH')
cy.url().should('match', /\/positions\/create\/v2\?currencyA=0x318400242bFdE3B20F49237a9490b8eBB6bdB761&currencyB=ETH/)
})
it('should redirect add v3 liquidity to positions create page', () => {
cy.visit('/add/0x318400242bFdE3B20F49237a9490b8eBB6bdB761/ETH')
cy.url().should('match', /\/positions\/create\/v3\?currencyA=0x318400242bFdE3B20F49237a9490b8eBB6bdB761&currencyB=ETH/)
})
it('should redirect remove v2 liquidity to positions page', () => {
cy.visit('/remove/v2/0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599/0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2')
cy.url().should('match', /\/positions\/v2\/ethereum\/0xBb2b8038a1640196FbE3e38816F3e67Cba72D940/)
})
it('should redirect remove v3 liquidity to positions page', () => {
cy.visit('/remove/825708')
cy.url().should('match', /\/positions\/v3\/ethereum\/825708/)
})
})
import { NATIVE_CHAIN_ID } from 'constants/tokens'
import { UNI } from 'uniswap/src/constants/tokens'
import { UniverseChainId } from 'uniswap/src/features/chains/types'
import { getTestSelector } from '../utils'
const UNI_ADDRESS = UNI[UniverseChainId.Mainnet].address.toLowerCase()
describe('Universal search bar', () => {
function openSearch() {
// can't just type "/" because on mobile it doesn't respond to that
return cy.get('[data-cy="nav-search-container"] [data-cy="nav-search-icon"]').first().click()
}
beforeEach(() => {
cy.visit('/')
})
function getSearchBar() {
return cy.get('[data-cy="nav-search-container"] input').click()
}
it('should yield clickable result that is then added to recent searches', () => {
// Search for UNI token by name.
openSearch()
getSearchBar().clear().type('uni')
cy.get(getTestSelector(`searchbar-token-row-ETHEREUM-${UNI_ADDRESS}`))
.should('contain.text', 'Uniswap')
.and('contain.text', 'UNI')
.and('contain.text', '$')
.and('contain.text', '%')
.click()
cy.location('pathname').should('equal', '/explore/tokens/ethereum/0x1f9840a85d5af5bf1d1762f925bdaddc4201f984')
openSearch()
cy.get(getTestSelector('searchbar-dropdown'))
.contains(getTestSelector('searchbar-dropdown'), 'Recent searches')
.find(getTestSelector(`searchbar-token-row-ETHEREUM-${UNI_ADDRESS}`))
.should('exist')
})
it('should go to the selected result when recent results are shown', () => {
// Seed recent results with UNI.
openSearch()
getSearchBar().type('uni')
cy.get(getTestSelector(`searchbar-token-row-ETHEREUM-${UNI_ADDRESS}`))
getSearchBar().clear().type('{esc}')
// Search a different token by name.
openSearch()
getSearchBar().type('eth')
cy.get(getTestSelector(`searchbar-token-row-ETHEREUM-${NATIVE_CHAIN_ID}`))
// Validate that we go to the searched/selected result.
cy.get(getTestSelector(`searchbar-token-row-ETHEREUM-${NATIVE_CHAIN_ID}`)).click()
cy.url().should('contain', `/explore/tokens/ethereum/${NATIVE_CHAIN_ID}`)
})
})
import { getTestSelector, resetHardhatChain } from '../../utils'
// TODO(WEB-4923): Re-enable network switching tests when theres a non-UI way to switch networks
// function switchChain(chain: string) {
// cy.get(getTestSelector('chain-selector')).click()
// cy.contains(chain).click()
// }
describe('network switching', () => {
beforeEach(() => {
cy.visit('/swap')
cy.get(getTestSelector('web3-status-connected'))
})
afterEach(resetHardhatChain)
// function rejectsNetworkSwitchWith(rejection: unknown) {
// cy.hardhat().then((hardhat) => {
// // Reject network switch
// const sendStub = cy.stub(hardhat.provider, 'send').log(false).as('switch')
// sendStub.withArgs('wallet_switchEthereumChain').rejects(rejection)
// sendStub.callThrough() // allows other calls to return non-stubbed values
// })
// switchChain('Polygon')
// // Verify rejected network switch
// cy.get('@switch').should('have.been.calledWith', 'wallet_switchEthereumChain')
// cy.get(getTestSelector('web3-status-connected'))
// }
// it('should not display message on user rejection', () => {
// const USER_REJECTION = { code: 4001 }
// rejectsNetworkSwitchWith(USER_REJECTION)
// cy.get(getTestSelector('popups')).should('not.contain', 'Failed to switch networks')
// })
// it('should display message on unknown error', () => {
// rejectsNetworkSwitchWith(new Error('Unknown error'))
// cy.get(getTestSelector('popups')).contains('Failed to switch networks')
// })
// it('should add missing chain', () => {
// cy.hardhat().then((hardhat) => {
// // https://docs.metamask.io/guide/rpc-api.html#unrestricted-methods
// const CHAIN_NOT_ADDED = { code: 4902 } // missing message in useSelectChain
// // Reject network switch with CHAIN_NOT_ADDED
// const sendStub = cy.stub(hardhat.provider, 'send').log(false).as('switch')
// let added = false
// sendStub
// .withArgs('wallet_switchEthereumChain')
// .callsFake(() => (added ? Promise.resolve(null) : Promise.reject(CHAIN_NOT_ADDED)))
// sendStub.withArgs('wallet_addEthereumChain').callsFake(() => {
// added = true
// return Promise.resolve(null)
// })
// sendStub.callThrough() // allows other calls to return non-stubbed values
// })
// switchChain('Polygon')
// // Verify the network was added
// cy.get('@switch').should('have.been.calledWith', 'wallet_switchEthereumChain')
// cy.get('@switch').should('have.been.calledWith', 'wallet_addEthereumChain')
// })
// it('should not disconnect while switching', () => {
// // Defer the connection so we can see the pending state
// cy.hardhat().then((hardhat) => {
// const sendStub = cy.stub(hardhat.provider, 'send').log(false).as('switch')
// sendStub.withArgs('wallet_switchEthereumChain').returns(new Promise(() => {}))
// sendStub.callThrough() // allows other calls to return non-stubbed values
// })
// switchChain('Polygon')
// // Verify there is no disconnection
// cy.get('@switch').should('have.been.calledWith', 'wallet_switchEthereumChain')
// cy.contains('Connecting to Polygon')
// cy.get(getTestSelector('web3-status-connected')).should('be.disabled')
// })
// it('switches networks', () => {
// // Select an output currency
// cy.get('#swap-currency-output .open-currency-select-button').click()
// cy.get(getTestSelector('token-option-1-USDT')).click()
// // Populate input/output fields
// cy.get('#swap-currency-input .token-amount-input').clear().type('1')
// cy.get('#swap-currency-output .token-amount-input').should('not.equal', '')
// // Switch network
// switchChain('Polygon')
// // Verify network switch
// cy.wait('@wallet_switchEthereumChain')
// cy.get(getTestSelector('web3-status-connected'))
// cy.url().should('contain', 'chain=polygon')
// // Verify that the input/output fields were reset
// cy.get('#swap-currency-input .token-amount-input').should('have.value', '')
// cy.get(`#swap-currency-input .token-symbol-container`).should('contain.text', 'MATIC')
// cy.get(`#swap-currency-output .token-amount-input`).should('not.have.value')
// cy.get(`#swap-currency-output .token-symbol-container`).should('contain.text', 'Select token')
// })
// describe('from URL param', () => {
// it('should switch network from URL param', () => {
// cy.visit('/swap?chain=optimism')
// cy.wait('@wallet_switchEthereumChain')
// cy.get(getTestSelector('web3-status-connected'))
// })
// it('should switch network with inputCurrency from URL param', () => {
// cy.visit('/swap?chain=optimism&outputCurrency=0x0b2c639c533813f4aa9d7837caf62653d097ff85')
// cy.wait('@wallet_switchEthereumChain')
// cy.get(getTestSelector('web3-status-connected'))
// cy.get(`#swap-currency-output .token-symbol-container`).should('contain.text', 'USDC')
// })
// it('should not switch network with no chain in param', () => {
// cy.hardhat().then((hardhat) => {
// cy.visit('/swap?outputCurrency=0x2260fac5e5542a773aa44fbcfedf7c193bc2c599')
// const sendSpy = cy.spy(hardhat.provider, 'send')
// cy.wrap(sendSpy).should('not.be.calledWith', 'wallet_switchEthereumChain')
// cy.wrap(hardhat.provider.network.chainId).should('eq', 1)
// cy.get(getTestSelector('web3-status-connected'))
// cy.get(`#swap-currency-output .token-symbol-container`).should('contain.text', 'WBTC')
// })
// })
// it('should be able to switch network after loading from URL param', () => {
// cy.visit('/swap?chain=optimism&outputCurrency=0x0b2c639c533813f4aa9d7837caf62653d097ff85')
// cy.wait('@wallet_switchEthereumChain')
// cy.get(getTestSelector('web3-status-connected'))
// cy.get(`#swap-currency-output .token-symbol-container`).should('contain.text', 'USDC')
// // switching to another chain clears query param
// switchChain('Ethereum')
// cy.wait('@wallet_switchEthereumChain')
// cy.url().should('not.contain', 'chain=optimism')
// cy.url().should('not.contain', 'outputCurrency=0xff970a61a04b1ca14834a43f5de4533ebddb5cc8')
// })
// })
describe('multichain', () => {
it('does not switchEthereumChain when multichain is enabled', () => {
cy.hardhat().then((hardhat) => {
cy.visit('/swap')
cy.get('#swap-currency-input .open-currency-select-button').click()
cy.get(getTestSelector('chain-selector')).last().click()
cy.contains('Arbitrum').click()
const sendSpy = cy.spy(hardhat.provider, 'send')
cy.wrap(sendSpy).should('not.be.calledWith', 'wallet_switchEthereumChain')
cy.wrap(hardhat.provider.network.chainId).should('eq', 1)
})
})
})
})
This diff is collapsed.
{
"data": {
"v3PoolsForTokenPair": [
{
"feeTier": 3000.0,
"token0Supply": 3084.78636993,
"token1Supply": 76658.86823272712,
"__typename": "V3Pool"
},
{
"feeTier": 500.0,
"token0Supply": 917.54087138,
"token1Supply": 22788.333363067817,
"__typename": "V3Pool"
},
{
"feeTier": 10000.0,
"token0Supply": 14.67505995,
"token1Supply": 276.9691974507059,
"__typename": "V3Pool"
},
{
"feeTier": 100.0,
"token0Supply": 0.04782301,
"token1Supply": 1.677630863841559,
"__typename": "V3Pool"
}
]
}
}
{
"countryCode": "US",
"displayName": "United States",
"state": "US-NY"
}
{
"error": null,
"message": null,
"quotes": [
{
"countryCode": "US",
"destinationAmount": 0.03131402821107613,
"destinationCurrencyCode": "ETH",
"isMostRecentlyUsedProvider": false,
"serviceProvider": "STRIPE",
"serviceProviderDetails": {
"logos": {
"darkLogo": "https://images-serviceprovider.meld.io/STRIPE/short_logo_light.png",
"lightLogo": "https://images-serviceprovider.meld.io/STRIPE/short_logo_light.png"
},
"name": "Stripe",
"paymentMethods": [
"Debit Card",
"ACH"
],
"serviceProvider": "STRIPE",
"supportUrl": "https://support.stripe.com/",
"url": "http://www.stripe.com"
},
"sourceAmount": 100,
"sourceCurrencyCode": "USD",
"totalFee": 2.19
},
{
"countryCode": "US",
"destinationAmount": 0.03034965,
"destinationCurrencyCode": "ETH",
"isMostRecentlyUsedProvider": true,
"serviceProvider": "COINBASEPAY",
"serviceProviderDetails": {
"logos": {
"darkLogo": "https://images-serviceprovider.meld.io/COINBASEPAY/short_logo_light.png",
"lightLogo": "https://images-serviceprovider.meld.io/COINBASEPAY/short_logo_light.png"
},
"name": "Coinbase",
"paymentMethods": [
"Debit Card",
"ACH"
],
"serviceProvider": "COINBASEPAY",
"supportUrl": "https://help.coinbase.com/",
"url": "https://www.coinbase.com/"
},
"sourceAmount": 100,
"sourceCurrencyCode": "USD",
"totalFee": 2.83
},
{
"countryCode": "US",
"destinationAmount": 0.0303,
"destinationCurrencyCode": "ETH",
"isMostRecentlyUsedProvider": false,
"serviceProvider": "MOONPAY",
"serviceProviderDetails": {
"logos": {
"darkLogo": "https://images-serviceprovider.meld.io/MOONPAY/short_logo_light.png",
"lightLogo": "https://images-serviceprovider.meld.io/MOONPAY/short_logo_light.png"
},
"name": "MoonPay",
"serviceProvider": "MOONPAY",
"supportUrl": "https://www.moonpay.com/contact-us",
"url": "https://buy.moonpay.com",
"paymentMethods": [
"Debit Card",
"Apple Pay",
"Google Pay",
"PayPal"
]
},
"sourceAmount": 100,
"sourceCurrencyCode": "USD",
"totalFee": 5.040000000000006
}
]
}
This diff is collapsed.
This diff is collapsed.
{
"errorCode": "QUOTE_ERROR",
"detail": "No quotes available",
"id": "63363cc1-d474-4584-b386-7c356814b79f"
}
\ No newline at end of file
{
"alpha2": "US",
"alpha3": "USA",
"country": "United States of America",
"ipAddress": "142.154.213.230",
"isAllowed": true,
"isBuyAllowed": false,
"isNftAllowed": true,
"isSellAllowed": false,
"isBalanceLedgerWithdrawAllowed": true,
"isLowLimitEnabled": false,
"state": "NY"
}
{
"alpha2": "US",
"alpha3": "USA",
"country": "United States of America",
"ipAddress": "142.154.213.230",
"isAllowed": true,
"isBuyAllowed": true,
"isNftAllowed": true,
"isSellAllowed": true,
"isBalanceLedgerWithdrawAllowed": true,
"isLowLimitEnabled": true,
"state": "NY"
}
This diff is collapsed.
This diff is collapsed.
{"data":{"searchTokens":[{"id":"VG9rZW46WktTWU5DX251bGw=","chain":"ZKSYNC","address":null,"decimals":18,"symbol":"ETH","name":"Ethereum","project":{"id":"VG9rZW5Qcm9qZWN0OkVUSEVSRVVNX251bGxfRXRoZXJldW0=","logoUrl":"https://token-icons.s3.amazonaws.com/eth.png","safetyLevel":"VERIFIED","__typename":"TokenProject"},"protectionInfo":null,"__typename":"Token"},{"id":"VG9rZW46V09STERDSEFJTl9udWxs","chain":"WORLDCHAIN","address":null,"decimals":18,"symbol":"ETH","name":"Ethereum","project":{"id":"VG9rZW5Qcm9qZWN0OkVUSEVSRVVNX251bGxfRXRoZXJldW0=","logoUrl":"https://token-icons.s3.amazonaws.com/eth.png","safetyLevel":"VERIFIED","__typename":"TokenProject"},"protectionInfo":null,"__typename":"Token"},{"id":"VG9rZW46QkxBU1RfbnVsbA==","chain":"BLAST","address":null,"decimals":18,"symbol":"ETH","name":"Ethereum","project":{"id":"VG9rZW5Qcm9qZWN0OkVUSEVSRVVNX251bGxfRXRoZXJldW0=","logoUrl":"https://token-icons.s3.amazonaws.com/eth.png","safetyLevel":"VERIFIED","__typename":"TokenProject"},"protectionInfo":null,"__typename":"Token"},{"id":"VG9rZW46Wk9SQV9udWxs","chain":"ZORA","address":null,"decimals":18,"symbol":"ETH","name":"Ethereum","project":{"id":"VG9rZW5Qcm9qZWN0OkVUSEVSRVVNX251bGxfRXRoZXJldW0=","logoUrl":"https://token-icons.s3.amazonaws.com/eth.png","safetyLevel":"VERIFIED","__typename":"TokenProject"},"protectionInfo":null,"__typename":"Token"},{"id":"VG9rZW46RVRIRVJFVU1fbnVsbA==","chain":"ETHEREUM","address":null,"decimals":18,"symbol":"ETH","name":"Ethereum","project":{"id":"VG9rZW5Qcm9qZWN0OkVUSEVSRVVNX251bGxfRXRoZXJldW0=","logoUrl":"https://token-icons.s3.amazonaws.com/eth.png","safetyLevel":"VERIFIED","__typename":"TokenProject"},"protectionInfo":null,"__typename":"Token"},{"id":"VG9rZW46QVJCSVRSVU1fbnVsbA==","chain":"ARBITRUM","address":null,"decimals":18,"symbol":"ETH","name":"Ethereum","project":{"id":"VG9rZW5Qcm9qZWN0OkVUSEVSRVVNX251bGxfRXRoZXJldW0=","logoUrl":"https://token-icons.s3.amazonaws.com/eth.png","safetyLevel":"VERIFIED","__typename":"TokenProject"},"protectionInfo":null,"__typename":"Token"},{"id":"VG9rZW46T1BUSU1JU01fbnVsbA==","chain":"OPTIMISM","address":null,"decimals":18,"symbol":"ETH","name":"Ethereum","project":{"id":"VG9rZW5Qcm9qZWN0OkVUSEVSRVVNX251bGxfRXRoZXJldW0=","logoUrl":"https://token-icons.s3.amazonaws.com/eth.png","safetyLevel":"VERIFIED","__typename":"TokenProject"},"protectionInfo":null,"__typename":"Token"},{"id":"VG9rZW46T1BUSU1JU01fbnVsbA==","chain":"BASE","address":null,"decimals":18,"symbol":"ETH","name":"Ethereum","project":{"id":"VG9rZW5Qcm9qZWN0OkVUSEVSRVVNX251bGxfRXRoZXJldW0=","logoUrl":"https://token-icons.s3.amazonaws.com/eth.png","safetyLevel":"VERIFIED","__typename":"TokenProject"},"protectionInfo":null,"__typename":"Token"},{"id":"VG9rZW46RVRIRVJFVU1fMHhDMDJhYUEzOWIyMjNGRThEMEEwZTVDNEYyN2VBRDkwODNDNzU2Q2My","chain":"ETHEREUM","address":"0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2","decimals":18,"symbol":"WETH","name":"Wrapped Ether","project":{"id":"VG9rZW5Qcm9qZWN0OkVUSEVSRVVNXzB4YzAyYWFhMzliMjIzZmU4ZDBhMGU1YzRmMjdlYWQ5MDgzYzc1NmNjMl9XRVRI","logoUrl":"https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png","safetyLevel":"VERIFIED","__typename":"TokenProject"},"protectionInfo":{"result":"BENIGN","attackTypes":[],"__typename":"ProtectionInfo"},"__typename":"Token"},{"id":"VG9rZW46QVJCSVRSVU1fMHg4MmFmNDk0NDdkOGEwN2UzYmQ5NWJkMGQ1NmYzNTI0MTUyM2ZiYWIx","chain":"ARBITRUM","address":"0x82af49447d8a07e3bd95bd0d56f35241523fbab1","decimals":18,"symbol":"WETH","name":"Wrapped Ether","project":{"id":"VG9rZW5Qcm9qZWN0OkVUSEVSRVVNXzB4YzAyYWFhMzliMjIzZmU4ZDBhMGU1YzRmMjdlYWQ5MDgzYzc1NmNjMl9BcmJpdHJ1bSBCcmlkZ2VkIFdFVEggKEFyYml0cnVtIE9uZSk=","logoUrl":"https://coin-images.coingecko.com/coins/images/39713/large/WETH.PNG?1723731496","safetyLevel":"VERIFIED","__typename":"TokenProject"},"protectionInfo":{"result":"BENIGN","attackTypes":[],"__typename":"ProtectionInfo"},"__typename":"Token"}]}}
\ No newline at end of file
{"data":{"searchTokens":[{"id":"VG9rZW46RVRIRVJFVU1fMHhhMGI4Njk5MWM2MjE4YjM2YzFkMTlkNGEyZTllYjBjZTM2MDZlYjQ4","chain":"ETHEREUM","address":"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48","decimals":6,"symbol":"USDC","project":{"id":"VG9rZW5Qcm9qZWN0OkVUSEVSRVVNXzB4YTBiODY5OTFjNjIxOGIzNmMxZDE5ZDRhMmU5ZWIwY2UzNjA2ZWI0OF9VU0RD","name":"USDC","logoUrl":"https://coin-images.coingecko.com/coins/images/6319/large/usdc.png?1696506694","safetyLevel":"VERIFIED","__typename":"TokenProject"},"__typename":"Token"}]}}
\ No newline at end of file
This diff is collapsed.
{"data":{"tokenProjects":[{"id":"VG9rZW5Qcm9qZWN0OkVUSEVSRVVNX251bGxfRXRoZXJldW0=","name":"Ethereum","logoUrl":"https://token-icons.s3.amazonaws.com/eth.png","safetyLevel":"VERIFIED","tokens":[{"chain":"ETHEREUM","address":null,"decimals":18,"symbol":"ETH","__typename":"Token"},{"chain":"ARBITRUM","address":null,"decimals":18,"symbol":"ETH","__typename":"Token"},{"chain":"OPTIMISM","address":null,"decimals":18,"symbol":"ETH","__typename":"Token"},{"chain":"BASE","address":null,"decimals":18,"symbol":"ETH","__typename":"Token"},{"chain":"BLAST","address":null,"decimals":18,"symbol":"ETH","__typename":"Token"},{"chain":"ZORA","address":null,"decimals":18,"symbol":"ETH","__typename":"Token"},{"chain":"ZKSYNC","address":null,"decimals":18,"symbol":"ETH","__typename":"Token"}],"__typename":"TokenProject"}]}}
\ No newline at end of file
This diff is collapsed.
{"data":{"token":{"id":"VG9rZW46RVRIRVJFVU1fMHgxZjk4NDBhODVkNWFmNWJmMWQxNzYyZjkyNWJkYWRkYzQyMDFmOTg0","address":"0x1f9840a85d5af5bf1d1762f925bdaddc4201f984","chain":"ETHEREUM","decimals":18,"name":"Uniswap","standard":"ERC20","symbol":"UNI","project":{"id":"VG9rZW5Qcm9qZWN0OkVUSEVSRVVNXzB4MWY5ODQwYTg1ZDVhZjViZjFkMTc2MmY5MjViZGFkZGM0MjAxZjk4NF9Vbmlzd2Fw","isSpam":false,"logoUrl":"https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984/logo.png","name":"Uniswap","safetyLevel":"VERIFIED","__typename":"TokenProject"},"feeData":{"buyFeeBps":null,"sellFeeBps":null,"__typename":"FeeData"},"__typename":"Token"}}}
\ No newline at end of file
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment