diff --git a/.prettierignore b/.prettierignore index fe5f5f88cd3..84141d7a1cb 100644 --- a/.prettierignore +++ b/.prettierignore @@ -14,6 +14,7 @@ __mocks__ *.inc *.json *.md +*.test.ts *.yml babel.config.js build diff --git a/.yarn/patches/detox-npm-20.18.1-b532b310b4.patch b/.yarn/patches/detox-npm-20.18.1-b532b310b4.patch new file mode 100644 index 00000000000..540b89a9292 --- /dev/null +++ b/.yarn/patches/detox-npm-20.18.1-b532b310b4.patch @@ -0,0 +1,14 @@ +diff --git a/android/rninfo.gradle b/android/rninfo.gradle +index c09d2af1d219a4134dc0301e9270aef568730d2b..f1b887cf5dcf56c2f66fff3e6f1b674d48704dac 100644 +--- a/android/rninfo.gradle ++++ b/android/rninfo.gradle +@@ -3,7 +3,8 @@ import groovy.json.JsonSlurper + def getRNVersion = { workingDir -> + println("RNInfo: workingDir=$workingDir") + def jsonSlurper = new JsonSlurper() +- def packageFile = "$workingDir/../node_modules/react-native/package.json" ++ // Fixes patch to node_modules in monorepo project ++ def packageFile = "$workingDir/../../../node_modules/react-native/package.json" + println("RNInfo: reading $packageFile") + Map packageJSON = jsonSlurper.parse(new File(packageFile)) + String rnVersion = packageJSON.get('version') diff --git a/.yarn/patches/detox-npm-20.23.0-6d61110e63.patch b/.yarn/patches/detox-npm-20.23.0-6d61110e63.patch deleted file mode 100644 index 509f663b9d1..00000000000 Binary files a/.yarn/patches/detox-npm-20.23.0-6d61110e63.patch and /dev/null differ diff --git a/CODEOWNERS b/CODEOWNERS deleted file mode 100644 index f70773659eb..00000000000 --- a/CODEOWNERS +++ /dev/null @@ -1 +0,0 @@ -* @uniswap/web-admins diff --git a/README.md b/README.md index 2b2d52e66e2..eb7520967c3 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ An open source repository for all Uniswap front end interfaces maintained by Uni ## Interfaces - Web: [app.uniswap.org](https://app.uniswap.org) -- Wallet: [wallet.uniswap.org](https://wallet.uniswap.org) +- Wallet (mobile + extension): [wallet.uniswap.org](https://wallet.uniswap.org) ## Socials / Contact @@ -31,6 +31,7 @@ For instructions per application or package, see the README published for each a - [Web](apps/web/README.md) - [Mobile](apps/mobile/README.md) +- [Extension](apps/extension/README.md) ## Releases @@ -43,7 +44,7 @@ Translations for our applications are done through [crowdin](https://crowdin.com | App | Coverage | | ------- | -------- | | web | [![Crowdin](https://badges.crowdin.net/uniswap-interface/localized.svg)](https://crowdin.com/project/uniswap-interface) | -| mobile | [![Crowdin](https://badges.crowdin.net/uniswap-wallet/localized.svg)](https://crowdin.com/project/uniswap-wallet) | +| wallet | [![Crowdin](https://badges.crowdin.net/uniswap-wallet/localized.svg)](https://crowdin.com/project/uniswap-wallet) | ## 🗂 Directory Structure diff --git a/RELEASE b/RELEASE index 87dfb045593..3144e55e250 100644 --- a/RELEASE +++ b/RELEASE @@ -1,24 +1,9 @@ -IPFS hash of the deployment: -- CIDv0: `QmdnXm6P6JYgiiW7hLc8cFmDMQYLE2WuZWAw6JMhxzQTby` -- CIDv1: `bafybeihfqcgfsnr2ocpxfqatxukii6i6vzrv2vjxm7qwnsxemv7sskmudq` - -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://bafybeihfqcgfsnr2ocpxfqatxukii6i6vzrv2vjxm7qwnsxemv7sskmudq.ipfs.dweb.link/ -- https://bafybeihfqcgfsnr2ocpxfqatxukii6i6vzrv2vjxm7qwnsxemv7sskmudq.ipfs.cf-ipfs.com/ -- [ipfs://QmdnXm6P6JYgiiW7hLc8cFmDMQYLE2WuZWAw6JMhxzQTby/](ipfs://QmdnXm6P6JYgiiW7hLc8cFmDMQYLE2WuZWAw6JMhxzQTby/) - -### 5.40.1 (2024-07-15) - - -### Bug Fixes - -* **web:** [ext-gtm] Remove outline on button on pageload - prod (#10194) 623eeca +Developed by Uniswap Labs, the Uniswap Extension allows you to access and explore onchain apps while maintaining full control of your crypto assets. The Uniswap Extension is the first wallet extension to live in your browser’s sidebar, persisting no matter where you are on the web. So you can explore crypto without obstructing your window or losing your place. +- Connect to thousands of onchain apps across Ethereum, Base, Arbitrum and many other EVM blockchains +- Explore tokens directly on Uniswap, one of the most trusted DeFi protocols with over $2 trillion in volume +- View all of your crypto assets without switching, across Ethereum and other EVM-compatible blockchains +- Easily create a new wallet or import your existing wallet +- Safely send and receive crypto tokens with other wallets +The Uniswap Extension’s source code is audited by the security firm Trail of Bits as an added measure to ensure that your crypto assets are safe. diff --git a/VERSION b/VERSION index 2a30392f23e..74fce0ec11b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -web/5.40.1 \ No newline at end of file +extension/1.0.3 \ No newline at end of file diff --git a/apps/extension/.depcheckrc b/apps/extension/.depcheckrc new file mode 100644 index 00000000000..ed92562bf95 --- /dev/null +++ b/apps/extension/.depcheckrc @@ -0,0 +1,17 @@ +ignores: [ + # Dependencies that depcheck thinks are unused but are actually used + "react-native-web", + "jest-environment-jsdom", + "webpack-cli", + # Dependencies that depcheck thinks are missing but are actually present or never used + ## Internal packages / workspaces + "src", + "tsconfig", + # Webpack plugins + "@svgr/webpack", + "tamagui-loader", + "esbuild-loader", + "swc-loader", + ## Testing + "@testing-library/dom", + ] diff --git a/apps/extension/.eslintignore b/apps/extension/.eslintignore new file mode 100644 index 00000000000..8e9904e772b --- /dev/null +++ b/apps/extension/.eslintignore @@ -0,0 +1 @@ +jest-setup.js diff --git a/apps/extension/.eslintrc.js b/apps/extension/.eslintrc.js new file mode 100644 index 00000000000..f21f9aa9ba3 --- /dev/null +++ b/apps/extension/.eslintrc.js @@ -0,0 +1,28 @@ +module.exports = { + root: true, + extends: ['@uniswap/eslint-config/native'], + ignorePatterns: ['node_modules', 'dist', '.turbo', 'build', '.eslintrc.js', 'webpack.config.js', 'webpack.dev.config.js', 'manifest.json'], + parserOptions: { + project: 'tsconfig.json', + tsconfigRootDir: __dirname, + ecmaFeatures: { + jsx: true, + }, + ecmaVersion: 2018, + sourceType: 'module', + }, + overrides: [ + { + files: ['*.ts', '*.tsx'], + rules: { + 'no-relative-import-paths/no-relative-import-paths': [ + 'error', + { + allowSameFolder: false, + }, + ], + }, + }, + ], + rules: {}, +} diff --git a/apps/extension/.gitignore b/apps/extension/.gitignore new file mode 100644 index 00000000000..085e25d9614 --- /dev/null +++ b/apps/extension/.gitignore @@ -0,0 +1,30 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dev +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +.tamagui + +# Sentry Config File +.env.sentry-build-plugin diff --git a/apps/extension/README.md b/apps/extension/README.md new file mode 100644 index 00000000000..371217ca5fd --- /dev/null +++ b/apps/extension/README.md @@ -0,0 +1,53 @@ +# Uniswap Extension + +## Developer Quickstart + +### Running the extension locally + +To run the extension, run the following from the top level of the monorepo: + +```bash +yarn +yarn extension start +``` + +### 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** +3. Click **Load unpacked** +4. Find and select the extension folder (apps/extension/dev) + +## Running the extension locally with an absolute path (for testing scantastic) + +Our scantastic API requires a consistent origin header so the build must be loaded from an absolute path. This works because Chrome generates a consistent ID for the extension based on the path it was loaded from. + +To run the extension, run the following from the top level of the monorepo: + +Mac: + +```bash +yarn +yarn extension start:absolute +``` + +Windows: + +```bash +yarn +yarn extension start:absolute:windows +``` + +1. Go to **chrome://extensions** +2. At the top right, turn on **Developer mode** +3. Click **Load unpacked** +4. Find and select the extension folder with an absolute path (`/Users/Shared/stretch` on Mac and `C:/ProgramData/stretch` on Windows) +5. Your chrome extension url should be `chrome-extension://ceofpnbcmdjbibjjdniemjemmgaibeih` on Mac and `chrome-extension://ffogefanhjekjafbpofianlhkonejcoe` on Windows. The backend allows this origin and the ID will be consistently generated based off an absolute path that is consistent on all machines. + +## Migrations + +We use `redux-persist` to persist the Redux state between user sessions. Most of this state is shared between the mobile app and the extension. Please review the [Wallet Migrations README](../../packages/wallet/src/state//README.md) for details on how to write migrations when you add or remove anything from the Redux state structure. diff --git a/apps/extension/jest-setup.js b/apps/extension/jest-setup.js new file mode 100644 index 00000000000..82ba3fa4ea8 --- /dev/null +++ b/apps/extension/jest-setup.js @@ -0,0 +1,71 @@ +import 'utilities/src/logger/mocks' + +import { chrome } from 'jest-chrome' +import { AppearanceSettingType } from 'wallet/src/features/appearance/slice' +import { TextEncoder, TextDecoder } from 'util'; + +process.env.IS_UNISWAP_EXTENSION = true + +global.TextEncoder = TextEncoder; +global.TextDecoder = TextDecoder; + +const ignoreLogs = { + error: [ + // We need to use _persist property to ensure that the state is properly + // rehydrated (https://github.com/Uniswap/universe/pull/7502/files#r1566259088) + 'Unexpected key "_persist" found in previous state received by the reducer.' + ] +} + +// Ignore certain logs that are expected during tests. +Object.entries(ignoreLogs).forEach(([method, messages]) => { + const key = method + const originalMethod = console[key] + console[key] = ((...args) => { + if (messages.some((message) => args.some((arg) => typeof arg === 'string' && arg.startsWith(message)))) { + return + } + originalMethod(...args) + }) +}) + +globalThis.matchMedia = + globalThis.matchMedia || + ((query) => { + const reducedMotion = query.match(/prefers-reduced-motion: ([a-zA-Z0-9-]+)/) + + return { + // Needed for reanimated to disable reduced motion warning in tests + matches: reducedMotion ? reducedMotion[1] === 'no-preference' : false, + addListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + } + }) + +require('react-native-reanimated').setUpTests() + +global.chrome = chrome + +jest.mock('src/app/navigation/utils', () => ({ + useExtensionNavigation: () => ({ + navigateTo: jest.fn(), + navigateBack: jest.fn(), + }) +})) + +jest.mock('wallet/src/features/focus/useIsFocused', () => { + return jest.fn().mockReturnValue(true) +}) + +const mockAppearanceSetting = AppearanceSettingType.System +jest.mock('wallet/src/features/appearance/hooks', () => { + return { + useCurrentAppearanceSetting: () => mockAppearanceSetting, + } +}) +jest.mock('wallet/src/features/appearance/hooks', () => { + return { + useSelectedColorScheme: () => 'light', + } +}) diff --git a/apps/extension/jest.config.js b/apps/extension/jest.config.js new file mode 100644 index 00000000000..a571f8b3f3f --- /dev/null +++ b/apps/extension/jest.config.js @@ -0,0 +1,58 @@ +const preset = require('../../config/jest-presets/jest/jest-preset') + +const fileExtensions = [ + 'eot', + 'gif', + 'jpeg', + 'jpg', + 'otf', + 'png', + 'ttf', + 'woff', + 'woff2', + 'mp4', +] + +module.exports = { + ...preset, + preset: 'jest-expo', + transform: { + '^.+\\.(t|j)sx?$': [ + 'babel-jest', + { + configFile: './src/test/babel.config.js', + } + ], + }, + moduleNameMapper: { + ...preset.moduleNameMapper, + '^react-native$': 'react-native-web', + }, + moduleFileExtensions: [ + 'web.js', + 'web.jsx', + 'web.ts', + 'web.tsx', + ...fileExtensions, + ...preset.moduleFileExtensions, + ], + resolver: "/src/test/jest-resolver.js", + displayName: 'Extension Wallet', + collectCoverageFrom: [ + 'src/app/**/*.{js,ts,tsx}', + 'src/background/**/*.{js,ts,tsx}', + 'src/contentScript/**/*.{js,ts,tsx}', + '!src/**/*.stories.**', + '!**/node_modules/**', + ], + coverageThreshold: { + global: { + lines: 0, + }, + }, + setupFiles: [ + '../../config/jest-presets/jest/setup.js', + './jest-setup.js', + '../../node_modules/react-native-gesture-handler/jestSetup.js', + ], +} diff --git a/apps/extension/package.json b/apps/extension/package.json new file mode 100644 index 00000000000..c4dccfd68f8 --- /dev/null +++ b/apps/extension/package.json @@ -0,0 +1,99 @@ +{ + "name": "@uniswap/extension", + "version": "0.0.0", + "browserslist": "last 2 chrome versions", + "dependencies": { + "@apollo/client": "3.10.4", + "@ethersproject/providers": "5.7.2", + "@metamask/rpc-errors": "6.2.1", + "@reduxjs/toolkit": "1.9.3", + "@sentry/browser": "7.80.0", + "@sentry/react": "7.80.0", + "@sentry/webpack-plugin": "2.10.3", + "@svgr/webpack": "8.0.1", + "@tamagui/core": "1.95.1", + "@types/uuid": "9.0.1", + "@uniswap/analytics-events": "2.32.0", + "@uniswap/universal-router-sdk": "2.2.0", + "@uniswap/v3-sdk": "3.13.0", + "dotenv-webpack": "8.0.1", + "ethers": "5.7.2", + "eventemitter3": "5.0.1", + "i18next": "23.10.0", + "node-polyfill-webpack-plugin": "2.0.1", + "react": "18.2.0", + "react-dom": "18.2.0", + "react-i18next": "14.1.0", + "react-native": "0.73.6", + "react-native-gesture-handler": "2.15.0", + "react-native-reanimated": "npm:react-native-reanimated@3.8.1", + "react-native-svg": "15.1.0", + "react-native-web": "0.19.10", + "react-qr-code": "2.0.12", + "react-redux": "8.0.5", + "react-router-dom": "6.10.0", + "redux": "4.2.1", + "redux-logger": "3.0.6", + "redux-persist": "6.0.0", + "redux-persist-webextension-storage": "1.0.2", + "redux-saga": "1.2.2", + "symbol-observable": "4.0.0", + "typed-redux-saga": "1.5.0", + "ua-parser-js": "1.0.37", + "ui": "workspace:^", + "uniswap": "workspace:^", + "utilities": "workspace:^", + "uuid": "9.0.0", + "wallet": "workspace:^", + "zod": "3.22.4" + }, + "devDependencies": { + "@pmmmwh/react-refresh-webpack-plugin": "^0.5.10", + "@testing-library/dom": "^7.11.0", + "@testing-library/react": "13.4.0", + "@types/chrome": "0.0.254", + "@types/jest": "29.5.0", + "@types/react": "^18.0.15", + "@types/react-dom": "^18.0.6", + "@types/redux-logger": "3.0.9", + "@types/redux-persist-webextension-storage": "1.0.3", + "@types/ua-parser-js": "0.7.31", + "@uniswap/eslint-config": "workspace:^", + "@welldone-software/why-did-you-render": "8.0.1", + "clean-webpack-plugin": "^4.0.0", + "copy-webpack-plugin": "^11.0.0", + "esbuild-loader": "^3.0.1", + "eslint": "8.44.0", + "jest": "29.7.0", + "jest-chrome": "0.8.0", + "jest-environment-jsdom": "29.5.0", + "jest-extended": "4.0.1", + "mini-css-extract-plugin": "^2.7.6", + "react-refresh": "^0.14.0", + "serve": "^14.2.0", + "statsig-js": "4.41.0", + "swc-loader": "^0.2.3", + "tamagui-loader": "1.95.1", + "typescript": "5.3.3", + "webpack": "5.90.0", + "webpack-cli": "^5.0.1", + "webpack-dev-server": "^4.13.1" + }, + "private": true, + "scripts": { + "build:production": "webpack --node-env=production --env BUILD_ENV=prod BUILD_NUM=${BUILD_NUM:-0}", + "check:deps:usage": "depcheck", + "env:local:download": "bash ../../scripts/downloadEnvLocal.sh web-local-envs ../../.env", + "env:local:upload": "bash ../../scripts/uploadEnvLocal.sh web-local-envs ../../.env", + "format": "../../scripts/prettier.sh", + "lint": "eslint . --ext ts,tsx --max-warnings=0", + "lint:fix": "eslint . --ext ts,tsx --fix", + "start": "webpack serve --config webpack.config.js", + "start:absolute": "yarn start:absolute:mac", + "start:absolute:mac": "yarn start --output-path /Users/Shared/stretch", + "start:absolute:windows": "yarn start --output-path C:/ProgramData/stretch", + "test": "jest", + "snapshots": "jest -u", + "typecheck": "tsc -b" + } +} diff --git a/apps/extension/src/app/Global.css b/apps/extension/src/app/Global.css new file mode 100644 index 00000000000..85648681006 --- /dev/null +++ b/apps/extension/src/app/Global.css @@ -0,0 +1,31 @@ +body, +html { + height: 100%; + max-width: 100vw; +} + +#root { + height: 100vh; + display: flex; + + scrollbar-width: 'thin'; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + +@keyframes shine { + from { + -webkit-mask-position: 150%; + } + to { + -webkit-mask-position: -50%; + } +} diff --git a/apps/extension/src/app/OnboardingApp.test.tsx b/apps/extension/src/app/OnboardingApp.test.tsx new file mode 100644 index 00000000000..c016ae7b8f8 --- /dev/null +++ b/apps/extension/src/app/OnboardingApp.test.tsx @@ -0,0 +1,10 @@ +import { render } from '@testing-library/react' +import OnboardingApp from 'src/app/OnboardingApp' +import { initializeReduxStore } from 'src/store/store' + +describe('OnboardingApp', () => { + it('renders without error', async () => { + await initializeReduxStore() + render() + }) +}) diff --git a/apps/extension/src/app/OnboardingApp.tsx b/apps/extension/src/app/OnboardingApp.tsx new file mode 100644 index 00000000000..7656388958d --- /dev/null +++ b/apps/extension/src/app/OnboardingApp.tsx @@ -0,0 +1,216 @@ +import '@tamagui/core/reset.css' +import 'src/app/Global.css' +import 'symbol-observable' // Needed by `reduxed-chrome-storage` as polyfill, order matters + +import { useEffect } from 'react' +import { I18nextProvider } from 'react-i18next' +import { RouteObject, RouterProvider } from 'react-router-dom' +import { PersistGate } from 'redux-persist/integration/react' +import { ExtensionStatsigProvider } from 'src/app/StatsigProvider' +import { GraphqlProvider } from 'src/app/apollo' +import { ErrorElement } from 'src/app/components/ErrorElement' +import { Complete } from 'src/app/features/onboarding/Complete' +import { + CreateOnboardingSteps, + ImportOnboardingSteps, + OnboardingStepsProvider, + ResetSteps, + ScanOnboardingSteps, +} from 'src/app/features/onboarding/OnboardingSteps' +import { OnboardingWrapper } from 'src/app/features/onboarding/OnboardingWrapper' +import { PasswordImport } from 'src/app/features/onboarding/PasswordImport' +import { NameWallet } from 'src/app/features/onboarding/create/NameWallet' +import { PasswordCreate } from 'src/app/features/onboarding/create/PasswordCreate' +import { TestMnemonic } from 'src/app/features/onboarding/create/TestMnemonic' +import { ViewMnemonic } from 'src/app/features/onboarding/create/ViewMnemonic' +import { ImportMnemonic } from 'src/app/features/onboarding/import/ImportMnemonic' +import { SelectWallets } from 'src/app/features/onboarding/import/SelectWallets' +import { IntroScreen } from 'src/app/features/onboarding/intro/IntroScreen' +import { IntroScreenBetaWaitlist } from 'src/app/features/onboarding/intro/IntroScreenBetaWaitlist' +import { UnsupportedBrowserScreen } from 'src/app/features/onboarding/intro/UnsupportedBrowserScreen' +import { ResetComplete } from 'src/app/features/onboarding/reset/ResetComplete' +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 { navigate, setRouter, setRouterState } from 'src/app/navigation/state' +import { sentryCreateHashRouter } from 'src/app/sentry' +import { initExtensionAnalytics } from 'src/app/utils/analytics' +import { checksIfSupportsSidePanel } from 'src/app/utils/chrome' +import { PrimaryAppInstanceDebuggerLazy } from 'src/store/PrimaryAppInstanceDebuggerLazy' +import { getReduxPersistor, getReduxStore } from 'src/store/store' +import { FeatureFlags } from 'uniswap/src/features/gating/flags' +import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { ExtensionEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { UnitagUpdaterContextProvider } from 'uniswap/src/features/unitags/context' +import i18n from 'uniswap/src/i18n/i18n' +import { ExtensionOnboardingFlow } from 'uniswap/src/types/screens/extension' +import { ErrorBoundary } from 'wallet/src/components/ErrorBoundary/ErrorBoundary' +import { LocalizationContextProvider } from 'wallet/src/features/language/LocalizationContext' +import { SharedProvider } from 'wallet/src/provider' + +const supportsSidePanel = checksIfSupportsSidePanel() + +const unsupportedRoute: RouteObject = { + path: '', + element: , +} + +const allRoutes = [ + { + path: '', + element: , + }, + { + path: OnboardingRoutes.UnsupportedBrowser, + element: , + }, + { + path: OnboardingRoutes.Create, + element: ( + + , + [CreateOnboardingSteps.ViewMnemonic]: , + [CreateOnboardingSteps.TestMnemonic]: , + [CreateOnboardingSteps.Naming]: , + [CreateOnboardingSteps.Complete]: , + }} + /> + + ), + }, + { + path: OnboardingRoutes.Import, + element: ( + + , + [ImportOnboardingSteps.Password]: , + [ImportOnboardingSteps.Select]: , + [ImportOnboardingSteps.Complete]: , + }} + /> + + ), + }, + { + path: OnboardingRoutes.Scan, + element: , + }, + { + path: OnboardingRoutes.ResetScan, + element: , + }, + { + path: OnboardingRoutes.Reset, + element: ( + + , + [ResetSteps.Password]: , + [ResetSteps.Select]: , + [ResetSteps.Complete]: , + }} + /> + + ), + }, +] + +const router = sentryCreateHashRouter([ + { + path: `/${TopLevelRoutes.Onboarding}`, + element: , + errorElement: , + children: !supportsSidePanel ? [unsupportedRoute] : allRoutes, + }, +]) + +function ScantasticFlow({ isResetting = false }: { isResetting?: boolean }): JSX.Element { + return ( + , + [ScanOnboardingSteps.OTP]: , + [ScanOnboardingSteps.Password]: , + [ScanOnboardingSteps.Select]: , + [ScanOnboardingSteps.Complete]: isResetting ? ( + + ) : ( + + ), + }} + /> + ) +} + +function IntroScreenBehindFeatureFlag(): JSX.Element { + const scantasticOnboardingOnly = useFeatureFlag(FeatureFlags.ScantasticOnboardingOnly) + return scantasticOnboardingOnly ? : +} + +function MaybeRedirectToScantastic({ children }: { children: JSX.Element }): JSX.Element | null { + const scantasticOnboardingOnly = useFeatureFlag(FeatureFlags.ScantasticOnboardingOnly) + if (scantasticOnboardingOnly) { + navigate(`/${TopLevelRoutes.Onboarding}`, { replace: true }) + return null + } + return children +} + +/** + * 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) => { + setRouterState(state) +}) + +setRouter(router) + +export default function OnboardingApp(): JSX.Element { + // initialize analytics on load + useEffect(() => { + async function initAndLogLoad(): Promise { + await initExtensionAnalytics() + sendAnalyticsEvent(ExtensionEventName.OnboardingLoad) + } + initAndLogLoad().catch(() => undefined) + }, []) + + return ( + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/apps/extension/src/app/SidebarApp.tsx b/apps/extension/src/app/SidebarApp.tsx new file mode 100644 index 00000000000..18891423472 --- /dev/null +++ b/apps/extension/src/app/SidebarApp.tsx @@ -0,0 +1,263 @@ +import '@tamagui/core/reset.css' +import 'src/app/Global.css' + +import { useEffect, useRef, useState } from 'react' +import { I18nextProvider } from 'react-i18next' +import { RouterProvider, ScrollRestoration } from 'react-router-dom' +import { PersistGate } from 'redux-persist/integration/react' +import { ExtensionStatsigProvider } from 'src/app/StatsigProvider' +import { GraphqlProvider } from 'src/app/apollo' +import { ErrorElement } from 'src/app/components/ErrorElement' +import { TraceUserProperties } from 'src/app/components/Trace/TraceUserProperties' +import { AccountSwitcherScreen } from 'src/app/features/accounts/AccountSwitcherScreen' +import { DappContextProvider } from 'src/app/features/dapp/DappContext' +import { addRequest } from 'src/app/features/dappRequests/saga' +import { ReceiveScreen } from 'src/app/features/receive/ReceiveScreen' +import { DevMenuScreen } from 'src/app/features/settings/DevMenuScreen' +import { SettingsPrivacyScreen } from 'src/app/features/settings/SettingsPrivacyScreen' +import { RemoveRecoveryPhraseVerify } from 'src/app/features/settings/SettingsRecoveryPhraseScreen/RemoveRecoveryPhraseVerify' +import { RemoveRecoveryPhraseWallets } from 'src/app/features/settings/SettingsRecoveryPhraseScreen/RemoveRecoveryPhraseWallets' +import { SettingsViewRecoveryPhraseScreen } from 'src/app/features/settings/SettingsRecoveryPhraseScreen/ViewRecoveryPhraseScreen' +import { SettingsScreen } from 'src/app/features/settings/SettingsScreen' +import { SettingsScreenWrapper } from 'src/app/features/settings/SettingsScreenWrapper' +import { SettingsChangePasswordScreen } from 'src/app/features/settings/password/SettingsChangePasswordScreen' +import { SwapFlowScreen } from 'src/app/features/swap/SwapFlowScreen' +import { TransferFlowScreen } from 'src/app/features/transfer/TransferFlowScreen' +import { useIsWalletUnlocked } from 'src/app/hooks/useIsWalletUnlocked' +import { MainContent, WebNavigation } from 'src/app/navigation' +import { AppRoutes, RemoveRecoveryPhraseRoutes, SettingsRoutes } from 'src/app/navigation/constants' +import { setRouter, setRouterState } from 'src/app/navigation/state' +import { SentryAppNameTag, initializeSentry, sentryCreateHashRouter } from 'src/app/sentry' +import { initExtensionAnalytics } from 'src/app/utils/analytics' +import { getLocalUserId } from 'src/app/utils/storage' +import { + DappBackgroundPortChannel, + createBackgroundToSidePanelMessagePort, +} from 'src/background/messagePassing/messageChannels' +import { BackgroundToSidePanelRequestType } from 'src/background/messagePassing/types/requests' +import { PrimaryAppInstanceDebuggerLazy } from 'src/store/PrimaryAppInstanceDebuggerLazy' +import { getReduxPersistor, getReduxStore, useAppDispatch } from 'src/store/store' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { ExtensionEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { UnitagUpdaterContextProvider } from 'uniswap/src/features/unitags/context' +import i18n from 'uniswap/src/i18n/i18n' +import { isDevEnv } from 'utilities/src/environment' +import { logger } from 'utilities/src/logger/logger' +import { ONE_SECOND_MS } from 'utilities/src/time/time' +import { useInterval } from 'utilities/src/time/timing' +import { ErrorBoundary } from 'wallet/src/components/ErrorBoundary/ErrorBoundary' +import { LocalizationContextProvider } from 'wallet/src/features/language/LocalizationContext' +import { syncAppWithDeviceLanguage } from 'wallet/src/features/language/slice' +import { SharedProvider } from 'wallet/src/provider' + +getLocalUserId() + .then((userId) => { + initializeSentry(SentryAppNameTag.Sidebar, userId) + }) + .catch((error) => { + logger.error(error, { + tags: { file: 'SidebarApp.tsx', function: 'getLocalUserId' }, + }) + }) + +const router = sentryCreateHashRouter([ + { + path: '', + element: , + errorElement: , + children: [ + { + path: '', + element: , + }, + { + path: AppRoutes.AccountSwitcher, + element: , + }, + { + path: AppRoutes.Settings, + element: , + children: [ + { + path: '', + element: , + }, + { + path: SettingsRoutes.ChangePassword, + element: , + }, + isDevEnv() + ? { + path: SettingsRoutes.DevMenu, + element: , + } + : {}, + { + path: SettingsRoutes.ViewRecoveryPhrase, + element: , + }, + { + path: SettingsRoutes.RemoveRecoveryPhrase, + children: [ + { + path: RemoveRecoveryPhraseRoutes.Wallets, + element: , + }, + { + path: RemoveRecoveryPhraseRoutes.Verify, + element: , + }, + ], + }, + { + path: SettingsRoutes.Privacy, + element: , + }, + ], + }, + { + path: AppRoutes.Transfer, + element: , + }, + { + path: AppRoutes.Swap, + element: , + }, + { + path: AppRoutes.Receive, + element: , + }, + ], + }, +]) + +const PORT_PING_INTERVAL = 5 * ONE_SECOND_MS +function useDappRequestPortListener(): void { + const dispatch = useAppDispatch() + const [currentPortChannel, setCurrentPortChannel] = useState() + const [windowId, setWindowId] = useState() + + useEffect(() => { + chrome.windows.getCurrent((window) => { + setWindowId(window.id?.toString()) + }) + + return () => currentPortChannel?.port.disconnect() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + useEffect(() => { + if (windowId === undefined || currentPortChannel) { + return + } + + try { + const port = chrome.runtime.connect({ name: windowId.toString() }) + const portChannel = createBackgroundToSidePanelMessagePort(port) + portChannel.addMessageListener(BackgroundToSidePanelRequestType.DappRequestReceived, (message) => { + const { dappRequest, senderTabInfo, isSidebarClosed } = message + dispatch( + addRequest({ + dappRequest, + senderTabInfo, + isSidebarClosed, + }), + ) + }) + + port.onDisconnect.addListener(() => { + sendAnalyticsEvent(ExtensionEventName.SidebarClosed) + setCurrentPortChannel(undefined) + }) + setCurrentPortChannel(portChannel) + } catch (error) { + logger.error(error, { + tags: { file: 'SidebarApp.tsx', function: 'useDappRequestPortListener' }, + }) + } + }, [dispatch, windowId, currentPortChannel]) + + useInterval(() => { + try { + // Need to send general ping message, no type-safety needed + currentPortChannel?.port.postMessage('statusPing') + } catch (error) { + currentPortChannel?.port.disconnect() + setCurrentPortChannel(undefined) + + logger.error(error, { + tags: { file: 'SidebarApp.tsx', function: 'useDappRequestPortListener' }, + }) + } + }, PORT_PING_INTERVAL) +} + +function SidebarWrapper(): JSX.Element { + const dispatch = useAppDispatch() + useDappRequestPortListener() + + useEffect(() => { + dispatch(syncAppWithDeviceLanguage()) + }, [dispatch]) + + return ( + <> + + + + ) +} + +/** + * 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) => { + setRouterState(state) +}) + +setRouter(router) + +export default function SidebarApp(): JSX.Element { + // initialize analytics on load + useEffect(() => { + initExtensionAnalytics().catch(() => undefined) + }, []) + + const isLoggedIn = useIsWalletUnlocked() + const hasSentLoginEvent = useRef(false) + useEffect(() => { + if (isLoggedIn !== null && !hasSentLoginEvent.current) { + hasSentLoginEvent.current = true + sendAnalyticsEvent(ExtensionEventName.SidebarLoad, { locked: !isLoggedIn }) + } + }, [isLoggedIn]) + + return ( + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/apps/extension/src/app/StatsigProvider.tsx b/apps/extension/src/app/StatsigProvider.tsx new file mode 100644 index 00000000000..c0102bdb782 --- /dev/null +++ b/apps/extension/src/app/StatsigProvider.tsx @@ -0,0 +1,53 @@ +import { getLocalUserId } from 'src/app/utils/storage' +import { getStatsigEnvironmentTier } from 'src/app/version' +import Statsig from 'statsig-js' // Use JS package for browser +import { uniswapUrls } from 'uniswap/src/constants/urls' +import { DUMMY_STATSIG_SDK_KEY, StatsigCustomAppValue } from 'uniswap/src/features/gating/constants' +import { StatsigOptions, StatsigProvider, StatsigUser } from 'uniswap/src/features/gating/sdk/statsig' +import { useAsyncData } from 'utilities/src/react/hooks' + +async function getStatsigUser(): Promise { + return { + userID: await getLocalUserId(), + appVersion: process.env.VERSION, + custom: { + app: StatsigCustomAppValue.Extension, + }, + } +} + +export function ExtensionStatsigProvider({ children }: { children: React.ReactNode }): JSX.Element { + const { data: user } = useAsyncData(getStatsigUser) + + const nonNullUser: StatsigUser = user ?? { + userID: undefined, + custom: { + app: StatsigCustomAppValue.Extension, + }, + appVersion: process.env.VERSION, + } + + const options: StatsigOptions = { + environment: { + tier: getStatsigEnvironmentTier(), + }, + api: uniswapUrls.statsigProxyUrl, + disableAutoMetricsLogging: true, + disableErrorLogging: true, + } + + return ( + + {children} + + ) +} + +export async function initStatSigForBrowserScripts(): Promise { + await Statsig.initialize(DUMMY_STATSIG_SDK_KEY, await getStatsigUser(), { + api: uniswapUrls.statsigProxyUrl, + environment: { + tier: getStatsigEnvironmentTier(), + }, + }) +} diff --git a/apps/extension/src/app/apollo.tsx b/apps/extension/src/app/apollo.tsx new file mode 100644 index 00000000000..450dbf02cb3 --- /dev/null +++ b/apps/extension/src/app/apollo.tsx @@ -0,0 +1,20 @@ +import { ApolloProvider } from '@apollo/client' +import { PropsWithChildren } from 'react' +import { localStorage } from 'redux-persist-webextension-storage' +// eslint-disable-next-line no-restricted-imports +import { usePersistedApolloClient } from 'wallet/src/data/apollo/usePersistedApolloClient' + +// Extension local storage has 10 MB limit, so we want to be very careful to leave enough space for the redux store + any other data that we might want to store in local storage +const MAX_CACHE_SIZE_IN_BYTES = 1024 * 1024 * 5 // 5 MB + +export function GraphqlProvider({ children }: PropsWithChildren): JSX.Element { + const apolloClient = usePersistedApolloClient({ + storageWrapper: localStorage, + maxCacheSizeInBytes: MAX_CACHE_SIZE_IN_BYTES, + }) + + if (!apolloClient) { + return <> + } + return {children} +} diff --git a/apps/extension/src/app/components/ComingSoon.tsx b/apps/extension/src/app/components/ComingSoon.tsx new file mode 100644 index 00000000000..682ce848e62 --- /dev/null +++ b/apps/extension/src/app/components/ComingSoon.tsx @@ -0,0 +1,33 @@ +import { PropsWithChildren } from 'react' +import { useTranslation } from 'react-i18next' +import { Flex, Text, Tooltip } from 'ui/src' + +type Side = 'top' | 'right' | 'bottom' | 'left' +type Alignment = 'start' | 'end' +type AlignedPlacement = `${Side}-${Alignment}` + +export function ComingSoon({ + children, + placement = 'bottom-end', +}: PropsWithChildren & { + placement?: Side | AlignedPlacement +}): JSX.Element { + const { t } = useTranslation() + + return ( + + + + {children} + + + + + + {t('settings.setting.beta.tooltip')} + + + + + ) +} diff --git a/apps/extension/src/app/components/ErrorElement.tsx b/apps/extension/src/app/components/ErrorElement.tsx new file mode 100644 index 00000000000..6875a070794 --- /dev/null +++ b/apps/extension/src/app/components/ErrorElement.tsx @@ -0,0 +1,13 @@ +import { PropsWithChildren } from 'react' +import { useRouteError } from 'react-router-dom' + +export function ErrorElement({ children }: PropsWithChildren): JSX.Element { + const error = useRouteError() + + if (!error) { + return <>{children} + } + + // Need to throw here to propagate to the ErrorBoundary + throw error +} diff --git a/apps/extension/src/app/components/Input.tsx b/apps/extension/src/app/components/Input.tsx new file mode 100644 index 00000000000..d7953e88d59 --- /dev/null +++ b/apps/extension/src/app/components/Input.tsx @@ -0,0 +1,38 @@ +import { forwardRef } from 'react' +import { Input as TamaguiInput, InputProps as TamaguiInputProps } from 'ui/src' +import { inputStyles } from 'ui/src/components/input/utils' +import { fonts } from 'ui/src/theme/fonts' + +export type InputProps = { + large?: boolean + hideInput?: boolean + centered?: boolean +} & TamaguiInputProps + +export type Input = TamaguiInput + +export const Input = forwardRef(function _Input( + { large = false, hideInput = false, centered = false, ...rest }: InputProps, + ref, +): JSX.Element { + return ( + + ) +}) diff --git a/apps/extension/src/app/components/MnemonicViewer.tsx b/apps/extension/src/app/components/MnemonicViewer.tsx new file mode 100644 index 00000000000..73ef4ddf95d --- /dev/null +++ b/apps/extension/src/app/components/MnemonicViewer.tsx @@ -0,0 +1,91 @@ +import { useCallback, useEffect, useMemo } from 'react' +import { CopyButton } from 'src/app/components/buttons/CopyButton' +import { Flex, Text, useMedia } from 'ui/src' +import { logger } from 'utilities/src/logger/logger' + +const ROW_SIZE = 3 + +export const MnemonicViewer = ({ mnemonic }: { mnemonic?: string[] }): JSX.Element => { + const media = useMedia() + const px = media.xxs ? '$spacing12' : '$spacing32' + + const onCopyPress = useCallback(async () => { + if (!mnemonic) { + return + } + const mnemonicString = mnemonic.join(' ') + try { + if (mnemonicString) { + await navigator.clipboard.writeText(mnemonicString) + } + } catch (error) { + logger.error(error, { + tags: { file: 'MnemonicViewer.tsx', function: 'onCopyPress' }, + }) + } + }, [mnemonic]) + + useEffect(() => { + return () => { + navigator.clipboard.writeText('').catch((error) => { + logger.error(error, { + tags: { file: 'MnemonicViewer.tsx', function: 'MnemonicViewer#useEffect' }, + }) + }) + } + }, []) + + const rows = useMemo(() => { + if (!mnemonic) { + return null + } + const elements = [] + for (let i = 0; i < mnemonic.length; i += ROW_SIZE) { + elements.push() + } + return elements + }, [mnemonic]) + + return ( + + {rows} + + + + + ) +} + +function SeedPhraseRow({ words, startIndex }: { words: string[]; startIndex: number }): JSX.Element { + return ( + + {words.map((word, index) => ( + + ))} + + ) +} + +function SeedPhraseWord({ index, word }: { index: number; word: string }): JSX.Element { + const media = useMedia() + const fontVariant = 'body3' + const gap = media.xxs ? '$spacing4' : '$spacing8' + return ( + + + {index} + + {word} + + ) +} diff --git a/apps/extension/src/app/components/OptionalStrictMode.tsx b/apps/extension/src/app/components/OptionalStrictMode.tsx new file mode 100644 index 00000000000..75de22e68ca --- /dev/null +++ b/apps/extension/src/app/components/OptionalStrictMode.tsx @@ -0,0 +1,8 @@ +import { StrictMode } from 'react' + +// TODO(EXT-1229): We had to remove `React.StrictMode` because it's not +// currently supported by Reanimated Web. We should consider re-enabling +// once Reanimated fixes this. +export function OptionalStrictMode(props: { children: React.ReactNode }): JSX.Element { + return process.env.ENABLE_STRICT_MODE ? {props.children} : <>{props.children} +} diff --git a/apps/extension/src/app/components/PasswordInput.tsx b/apps/extension/src/app/components/PasswordInput.tsx new file mode 100644 index 00000000000..7ba406b366a --- /dev/null +++ b/apps/extension/src/app/components/PasswordInput.tsx @@ -0,0 +1,66 @@ +import { forwardRef } from 'react' +import { TextInput } from 'react-native' +import { Input, InputProps } from 'src/app/components/Input' +import { Button, Flex, FlexProps, IconProps, Text } from 'ui/src' +import { Eye, EyeOff } from 'ui/src/components/icons' +import { PasswordStrength, getPasswordStrengthTextAndColor } from 'wallet/src/utils/password' + +export const PADDING_STRENGTH_INDICATOR = 76 + +const iconProps: IconProps = { + color: '$neutral3', + size: '$icon.20', +} +const hoverStyle: FlexProps = { + backgroundColor: 'transparent', +} + +interface PasswordInputProps extends InputProps { + passwordStrength?: PasswordStrength + hideInput: boolean + onToggleHideInput?: (hideInput: boolean) => void +} + +export const PasswordInput = forwardRef(function PasswordInput( + { passwordStrength, hideInput, onToggleHideInput, value, ...inputProps }, + ref, +): JSX.Element { + return ( + + + + {passwordStrength !== undefined ? ( + + ) : ( + onToggleHideInput && ( + + ) + )} + + ) +}) + +function StrengthIndicator({ strength }: { strength: PasswordStrength }): JSX.Element | null { + if (strength === PasswordStrength.NONE) { + return null + } + + const { text, color } = getPasswordStrengthTextAndColor(strength) + + return ( + + + {text} + + + ) +} diff --git a/apps/extension/src/app/components/Trace/TraceUserProperties.tsx b/apps/extension/src/app/components/Trace/TraceUserProperties.tsx new file mode 100644 index 00000000000..0d66f9f6f7f --- /dev/null +++ b/apps/extension/src/app/components/Trace/TraceUserProperties.tsx @@ -0,0 +1,72 @@ +import { useEffect } from 'react' +import { useColorScheme } from 'react-native' +import { ExtensionUserPropertyName, setUserProperty } from 'uniswap/src/features/telemetry/user' +// eslint-disable-next-line no-restricted-imports +import { analytics } from 'utilities/src/telemetry/analytics/analytics' +import { useAppFiatCurrencyInfo } from 'wallet/src/features/fiatCurrency/hooks' +import { useGatingUserPropertyUsernames } from 'wallet/src/features/gating/userPropertyHooks' +import { useCurrentLanguage } from 'wallet/src/features/language/hooks' +import { + useActiveAccount, + useHideSmallBalancesSetting, + useHideSpamTokensSetting, + useSignerAccounts, + useViewOnlyAccounts, +} from 'wallet/src/features/wallet/hooks' + +/** Component that tracks UserProperties during the lifetime of the app */ +export function TraceUserProperties(): null { + const colorScheme = useColorScheme() + const viewOnlyAccounts = useViewOnlyAccounts() + const activeAccount = useActiveAccount() + const signerAccounts = useSignerAccounts() + const hideSmallBalances = useHideSmallBalancesSetting() + const hideSpamTokens = useHideSpamTokensSetting() + const currentLanguage = useCurrentLanguage() + const appFiatCurrencyInfo = useAppFiatCurrencyInfo() + + useGatingUserPropertyUsernames() + + useEffect(() => { + setUserProperty(ExtensionUserPropertyName.AppVersion, chrome.runtime.getManifest().version) + return () => { + analytics.flushEvents() + } + }, []) + + useEffect(() => { + setUserProperty(ExtensionUserPropertyName.DarkMode, colorScheme === 'dark') + }, [colorScheme]) + + useEffect(() => { + setUserProperty(ExtensionUserPropertyName.WalletSignerCount, signerAccounts.length) + setUserProperty( + ExtensionUserPropertyName.WalletSignerAccounts, + signerAccounts.map((account) => account.address), + ) + }, [signerAccounts]) + + useEffect(() => { + setUserProperty(ExtensionUserPropertyName.WalletViewOnlyCount, viewOnlyAccounts.length) + }, [viewOnlyAccounts]) + + useEffect(() => { + if (!activeAccount) { + return + } + setUserProperty(ExtensionUserPropertyName.ActiveWalletAddress, activeAccount.address) + setUserProperty(ExtensionUserPropertyName.ActiveWalletType, activeAccount.type) + setUserProperty(ExtensionUserPropertyName.IsHideSmallBalancesEnabled, hideSmallBalances) + setUserProperty(ExtensionUserPropertyName.IsHideSpamTokensEnabled, hideSpamTokens) + }, [activeAccount, hideSmallBalances, hideSpamTokens]) + + useEffect(() => { + setUserProperty(ExtensionUserPropertyName.Language, currentLanguage) + }, [currentLanguage]) + + useEffect(() => { + setUserProperty(ExtensionUserPropertyName.Currency, appFiatCurrencyInfo.code) + }, [appFiatCurrencyInfo]) + + return null +} diff --git a/apps/extension/src/app/components/buttons/CopyButton.tsx b/apps/extension/src/app/components/buttons/CopyButton.tsx new file mode 100644 index 00000000000..ac94f03c820 --- /dev/null +++ b/apps/extension/src/app/components/buttons/CopyButton.tsx @@ -0,0 +1,75 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { AnimatePresence, Flex, Text, TouchableArea } from 'ui/src' +import { Check, CopySheets } from 'ui/src/components/icons' +import { iconSizes, zIndices } from 'ui/src/theme' + +export function CopyButton({ onCopyPress }: { onCopyPress: () => Promise }): JSX.Element { + const { t } = useTranslation() + + const [valueCopied, setValueCopied] = useState(false) + + const onPress = async (): Promise => { + await onCopyPress() + setValueCopied(true) + } + + return ( + + + + + {/* note there's various x/y adjustments here due to visual imbalance of icons/text */} + + {valueCopied ? ( + // check icon is a bit smaller and to the right + + ) : ( + + )} + + {valueCopied ? t('common.button.copied') : t('common.button.copy')} + + + + + + + ) +} diff --git a/apps/extension/src/app/components/layout/ScreenHeader.tsx b/apps/extension/src/app/components/layout/ScreenHeader.tsx new file mode 100644 index 00000000000..980a327454e --- /dev/null +++ b/apps/extension/src/app/components/layout/ScreenHeader.tsx @@ -0,0 +1,34 @@ +import { useExtensionNavigation } from 'src/app/navigation/utils' +import { Flex, GeneratedIcon, IconProps, Text, TouchableArea } from 'ui/src' +import { BackArrow } from 'ui/src/components/icons' +import { iconSizes } from 'ui/src/theme' + +export function ScreenHeader({ + onBackClick, + title, + rightColumn, + Icon = BackArrow, +}: { + title?: JSX.Element | string + onBackClick?: () => void + rightColumn?: JSX.Element + Icon?: GeneratedIcon | ((props: IconProps) => JSX.Element) +}): JSX.Element { + const { navigateBack } = useExtensionNavigation() + + return ( + + + + + + {/* When there's no right column, we adjust the margin to match the icon width. This is so that the title is centered on the screen. */} + + {/* // Render empty string if no title to account for Text element added padding for consistent size*/} + {title ?? ' '} + + + {rightColumn && {rightColumn}} + + ) +} diff --git a/apps/extension/src/app/components/loading/LoadingSpinner.tsx b/apps/extension/src/app/components/loading/LoadingSpinner.tsx new file mode 100644 index 00000000000..ebf93d8c501 --- /dev/null +++ b/apps/extension/src/app/components/loading/LoadingSpinner.tsx @@ -0,0 +1,28 @@ +import { Flex } from 'ui/src' +import { LoadingSpinnerInner, LoadingSpinnerOuter } from 'ui/src/components/icons' + +const SPINNER_HEIGHT = 80 + +export function LoadingSpinner(): JSX.Element { + return ( + <> + + + + + + + + + + ) +} + +const SPIN_SPEED_MS = 1000 diff --git a/apps/extension/src/app/components/loading/SelectWalletSkeleton.tsx b/apps/extension/src/app/components/loading/SelectWalletSkeleton.tsx new file mode 100644 index 00000000000..1a4cbcb8e55 --- /dev/null +++ b/apps/extension/src/app/components/loading/SelectWalletSkeleton.tsx @@ -0,0 +1,43 @@ +import { SkeletonBox } from 'src/app/components/loading/SkeletonBox' +import { Flex } from 'ui/src' +import { WALLET_PREVIEW_CARD_HEIGHT } from 'wallet/src/components/WalletPreviewCard/WalletPreviewCard' + +export function SelectWalletsSkeleton({ repeat = 3 }: { repeat?: number }): JSX.Element { + return ( + + {new Array(repeat).fill(null).map((_, i, { length }) => ( + + ))} + + ) +} + +function WalletSkeleton({ opacity }: { opacity: number }): JSX.Element { + return ( + + + + + + + + + + + ) +} diff --git a/apps/extension/src/app/components/loading/SkeletonBox.css b/apps/extension/src/app/components/loading/SkeletonBox.css new file mode 100644 index 00000000000..aebf9b3e3e0 --- /dev/null +++ b/apps/extension/src/app/components/loading/SkeletonBox.css @@ -0,0 +1,40 @@ +.skeleton-box { + display: inline-block; + height: 1em; + position: relative; + overflow: hidden; +} + +.skeleton-box::after { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + transform: translateX(-100%); + background-image: linear-gradient( + -75deg, + rgba(240, 240, 240, 0) 0, + rgba(240, 240, 240, 0.2) 20%, + rgba(240, 240, 240, 0.5) 60%, + rgba(240, 240, 240, 0) + ); + animation: skeleton-box-shimmer 1s linear infinite; + content: ''; +} + +.t_dark .skeleton-box::after { + background-image: linear-gradient( + -75deg, + rgba(30, 30, 30, 0) 0, + rgba(30, 30, 30, 0.2) 20%, + rgba(30, 30, 30, 0.5) 60%, + rgba(30, 30, 30, 0) + ); +} + +@keyframes skeleton-box-shimmer { + 100% { + transform: translateX(100%); + } +} diff --git a/apps/extension/src/app/components/loading/SkeletonBox.tsx b/apps/extension/src/app/components/loading/SkeletonBox.tsx new file mode 100644 index 00000000000..07291bb34ef --- /dev/null +++ b/apps/extension/src/app/components/loading/SkeletonBox.tsx @@ -0,0 +1,16 @@ +import 'src/app/components/loading/SkeletonBox.css' + +/** + * Unlike the `ui/src/Skeleton`, this `SkeletonBox` animation does not run in the main thread, so it won't be choppy if the main thread is busy. + */ +export function SkeletonBox({ + width = '100%', + height, + borderRadius = '5px', +}: { + width?: number | string + height: number | string + borderRadius?: string +}): JSX.Element { + return
+} diff --git a/apps/extension/src/app/components/modal/FeedbackRequestModal.tsx b/apps/extension/src/app/components/modal/FeedbackRequestModal.tsx new file mode 100644 index 00000000000..967b34fceb0 --- /dev/null +++ b/apps/extension/src/app/components/modal/FeedbackRequestModal.tsx @@ -0,0 +1,62 @@ +import { t } from 'i18next' +import { Button, Flex, Text, useSporeColors } from 'ui/src' +import { MessageStar } from 'ui/src/components/icons' +import { BottomSheetModal } from 'uniswap/src/components/modals/BottomSheetModal' +import { uniswapUrls } from 'uniswap/src/constants/urls' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { selectExtensionBetaFeedbackState } from 'wallet/src/features/behaviorHistory/selectors' +import { ExtensionBetaFeedbackState, setExtensionBetaFeedbackState } from 'wallet/src/features/behaviorHistory/slice' +import { useAppDispatch, useAppSelector } from 'wallet/src/state' + +export function FeedbackRequestModal(): JSX.Element { + const dispatch = useAppDispatch() + const colors = useSporeColors() + + const onDismiss = (): void => { + dispatch(setExtensionBetaFeedbackState(ExtensionBetaFeedbackState.Shown)) + } + + const openFeedbackUrl = (): void => { + // eslint-disable-next-line security/detect-non-literal-fs-filename + window.open(uniswapUrls.extensionFeedbackFormUrl, '_blank') + onDismiss() + } + + const isOpen = useAppSelector(selectExtensionBetaFeedbackState) === ExtensionBetaFeedbackState.ReadyToShow + + return ( + + + + + + + + {t('extension.feedback.title')} + + + {t('extension.feedback.description')} + + + + + + + + + ) +} diff --git a/apps/extension/src/app/components/modal/InfoModal.tsx b/apps/extension/src/app/components/modal/InfoModal.tsx new file mode 100644 index 00000000000..e254a9e8a53 --- /dev/null +++ b/apps/extension/src/app/components/modal/InfoModal.tsx @@ -0,0 +1,82 @@ +import { ReactNode } from 'react' +import { Anchor, Button, Flex, Text, TouchableArea, useSporeColors } from 'ui/src' +import { X } from 'ui/src/components/icons' +import { zIndices } from 'ui/src/theme' +import { BottomSheetModal } from 'uniswap/src/components/modals/BottomSheetModal' +import { ModalNameType } from 'uniswap/src/features/telemetry/constants' + +export interface BottomModalProps { + name: ModalNameType + isOpen: boolean + showCloseButton?: boolean + onDismiss?: () => void + icon: ReactNode + title: string + description: string + buttonText: string + buttonTheme?: 'primary' | 'secondary' | 'tertiary' + onButtonPress?: () => void + linkText?: string + linkUrl?: string +} + +export function InfoModal({ + name, + isOpen, + showCloseButton, + onDismiss, + icon, + title, + description, + buttonText, + buttonTheme, + onButtonPress, + linkText, + linkUrl, +}: React.PropsWithChildren): JSX.Element { + const colors = useSporeColors() + + return ( + + {showCloseButton && ( + + + + )} + + {icon} + + + {title} + + + {description} + + + + {linkText && linkUrl && ( + + + {linkText} + + + )} + + + ) +} diff --git a/apps/extension/src/app/components/tabs/ActivityTab.tsx b/apps/extension/src/app/components/tabs/ActivityTab.tsx new file mode 100644 index 00000000000..e79552959a3 --- /dev/null +++ b/apps/extension/src/app/components/tabs/ActivityTab.tsx @@ -0,0 +1,23 @@ +import { memo } from 'react' +import { ScrollView } from 'ui/src' +import { useActivityData } from 'wallet/src/features/activity/useActivityData' + +export const ActivityTab = memo(function _ActivityTab({ address }: { address: Address }): JSX.Element { + const { maybeLoaderComponent, maybeEmptyComponent, renderActivityItem, sectionData } = useActivityData({ + owner: address, + }) + + if (maybeLoaderComponent) { + return maybeLoaderComponent + } + + if (maybeEmptyComponent) { + return maybeEmptyComponent + } + + return ( + + {(sectionData ?? []).map((item) => renderActivityItem({ item }))} + + ) +}) diff --git a/apps/extension/src/app/components/tabs/NftsTab.tsx b/apps/extension/src/app/components/tabs/NftsTab.tsx new file mode 100644 index 00000000000..4ef7b375af4 --- /dev/null +++ b/apps/extension/src/app/components/tabs/NftsTab.tsx @@ -0,0 +1,100 @@ +import { SharedEventName } from '@uniswap/analytics-events' +import { memo, useCallback } from 'react' +import { ContextMenu, Flex } from 'ui/src' +import { fromGraphQLChain } from 'uniswap/src/features/chains/utils' +import { ElementName, SectionName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { UniverseChainId } from 'uniswap/src/types/chains' +import { NftsList } from 'wallet/src/components/nfts/NftsList' +import { selectNftsVisibility } from 'wallet/src/features/favorites/selectors' +import { NFTViewer } from 'wallet/src/features/images/NFTViewer' +import { ESTIMATED_NFT_LIST_ITEM_SIZE } from 'wallet/src/features/nfts/constants' +import { NFTItem } from 'wallet/src/features/nfts/types' +import { useNFTContextMenu } from 'wallet/src/features/nfts/useNftContextMenu' +import { getIsNftHidden } from 'wallet/src/features/nfts/utils' +import { useAppSelector } from 'wallet/src/state' + +export const NftsTab = memo(function _NftsTab({ owner }: { owner: Address }): JSX.Element { + const renderNFTItem = useCallback( + (item: NFTItem) => { + const onPress = (): void => { + sendAnalyticsEvent(SharedEventName.ELEMENT_CLICKED, { + element: ElementName.NftItem, + section: SectionName.HomeNFTsTab, + }) + } + + return + }, + [owner], + ) + + return ( + + ) +}) + +function NftView({ owner, item, onPress }: { owner: Address; item: NFTItem; onPress: () => void }): JSX.Element { + const { menuActions } = useNFTContextMenu({ + contractAddress: item.contractAddress, + tokenId: item.tokenId, + owner, + isSpam: item.isSpam, + chainId: fromGraphQLChain(item.chain) ?? UniverseChainId.Mainnet, + }) + + const menuOptions = menuActions.map((action) => ({ + label: action.title, + onPress: action.onPress, + Icon: action.Icon, + destructive: action.destructive, + })) + + const nftVisibility = useAppSelector(selectNftsVisibility) + const hidden = getIsNftHidden({ + contractAddress: item.contractAddress, + tokenId: item.tokenId, + isSpam: item.isSpam, + nftVisibility, + }) + + const itemId = `${item.chain}-${item.contractAddress}-${item.tokenId}-${hidden}` + + return ( + + + + + + + + ) +} + +const defaultEmptyStyle = { + minHeight: 100, + paddingVertical: '$spacing12', + width: '100%', +} diff --git a/apps/extension/src/app/constants.ts b/apps/extension/src/app/constants.ts new file mode 100644 index 00000000000..a27c12025f6 --- /dev/null +++ b/apps/extension/src/app/constants.ts @@ -0,0 +1,3 @@ +import { SpaceTokens } from 'ui/src' + +export const SCREEN_ITEM_HORIZONTAL_PAD = '$spacing12' satisfies SpaceTokens diff --git a/apps/extension/src/app/events/constants.ts b/apps/extension/src/app/events/constants.ts new file mode 100644 index 00000000000..7a81e3140fd --- /dev/null +++ b/apps/extension/src/app/events/constants.ts @@ -0,0 +1,3 @@ +export enum GlobalErrorEvent { + ReduxStorageExceeded = 'ReduxStorageExceeded', +} diff --git a/apps/extension/src/app/events/global.ts b/apps/extension/src/app/events/global.ts new file mode 100644 index 00000000000..d631a731e36 --- /dev/null +++ b/apps/extension/src/app/events/global.ts @@ -0,0 +1,5 @@ +import EventEmitter from 'eventemitter3' +import { GlobalErrorEvent } from 'src/app/events/constants' + +class GlobalEventEmitter extends EventEmitter {} +export const globalEventEmitter = new GlobalEventEmitter() diff --git a/apps/extension/src/app/features/accounts/AccountItem.tsx b/apps/extension/src/app/features/accounts/AccountItem.tsx new file mode 100644 index 00000000000..f7a5372367f --- /dev/null +++ b/apps/extension/src/app/features/accounts/AccountItem.tsx @@ -0,0 +1,192 @@ +import { SharedEventName } from '@uniswap/analytics-events' +import { BaseSyntheticEvent, useCallback, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { EditLabelModal } from 'src/app/features/accounts/EditLabelModal' +import { removeAllDappConnectionsForAccount } from 'src/app/features/dapp/actions' +import { ContextMenu, Flex, MenuContentItem, Text, TouchableArea } from 'ui/src' +import { CopySheets, Edit, TrashFilled, TripleDots } from 'ui/src/components/icons' +import { iconSizes } from 'ui/src/theme' +import { ElementName, ModalName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { NumberType } from 'utilities/src/format/types' +import { AddressDisplay } from 'wallet/src/components/accounts/AddressDisplay' +import { WarningModal } from 'wallet/src/components/modals/WarningModal/WarningModal' +import { usePortfolioTotalValue } from 'wallet/src/features/dataApi/balances' +import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' +import { pushNotification } from 'wallet/src/features/notifications/slice' +import { AppNotificationType, CopyNotificationType } from 'wallet/src/features/notifications/types' +import { WarningSeverity } from 'wallet/src/features/transactions/WarningModal/types' +import { EditAccountAction, editAccountActions } from 'wallet/src/features/wallet/accounts/editAccountSaga' +import { useActiveAccountWithThrow, useDisplayName, useSignerAccounts } from 'wallet/src/features/wallet/hooks' +import { DisplayNameType } from 'wallet/src/features/wallet/types' +import { useAppDispatch } from 'wallet/src/state' +import { setClipboard } from 'wallet/src/utils/clipboard' + +type AccountItemProps = { + address: Address + onAccountSelect?: () => void +} + +export function AccountItem({ address, onAccountSelect }: AccountItemProps): JSX.Element { + const { t } = useTranslation() + const dispatch = useAppDispatch() + const { data, loading, error } = usePortfolioTotalValue({ address }) + const { balanceUSD } = data || {} + + const { convertFiatAmountFormatted } = useLocalizationContext() + const formattedBalance = convertFiatAmountFormatted(balanceUSD, NumberType.PortfolioBalance) + + const [showEditLabelModal, setShowEditLabelModal] = useState(false) + + const displayName = useDisplayName(address) + const hasDisplayName = displayName?.type === DisplayNameType.Unitag || displayName?.type === DisplayNameType.ENS + + const accounts = useSignerAccounts() + const activeAccount = useActiveAccountWithThrow() + const activeAccountDisplayName = useDisplayName(activeAccount.address) + + const [showRemoveWalletModal, setShowRemoveWalletModal] = useState(false) + const onRemoveWallet = useCallback(async () => { + const accountForDeletion = accounts.find((account) => account.address === address) + if (!accountForDeletion) { + return + } + + await removeAllDappConnectionsForAccount(accountForDeletion) + dispatch( + editAccountActions.trigger({ + type: EditAccountAction.Remove, + accounts: [accountForDeletion], + }), + ) + }, [accounts, address, dispatch]) + + const onPressCopyAddress = useCallback( + async (e: BaseSyntheticEvent) => { + // We have to manually prevent click-through because the way the context menu is inside of a TouchableArea in this component it + // means that without it the TouchableArea handler will get called + // TODO(EXT-1325): Use a different ContextMenu component that works inside a TouchableArea + e.preventDefault() + e.stopPropagation() + + await setClipboard(address) + dispatch( + pushNotification({ + type: AppNotificationType.Copied, + copyType: CopyNotificationType.Address, + }), + ) + sendAnalyticsEvent(SharedEventName.ELEMENT_CLICKED, { + element: ElementName.CopyAddress, + modal: ModalName.AccountSwitcher, + }) + }, + [address, dispatch], + ) + + const menuOptions = useMemo((): MenuContentItem[] => { + return [ + // hide edit label if account has unitag or ENS + ...(!hasDisplayName + ? [ + { + label: t('account.wallet.menu.edit.title'), + onPress: (e: BaseSyntheticEvent): void => { + // We have to manually prevent click-through because the way the context menu is inside of a TouchableArea in this component it + // means that without it the TouchableArea handler will get called + e.preventDefault() + e.stopPropagation() + + setShowEditLabelModal(true) + }, + Icon: Edit, + }, + ] + : []), + + { + label: t('account.wallet.menu.copy.title'), + onPress: onPressCopyAddress, + Icon: CopySheets, + }, + { + label: t('account.wallet.menu.remove.title'), + onPress: (e: BaseSyntheticEvent): void => { + // We have to manually prevent click-through because the way the context menu is inside of a TouchableArea in this component it + // means that without it the TouchableArea handler will get called + e.preventDefault() + e.stopPropagation() + + setShowRemoveWalletModal(true) + }, + textProps: { color: '$statusCritical' }, + Icon: TrashFilled, + iconProps: { color: '$statusCritical' }, + }, + ] + }, [hasDisplayName, onPressCopyAddress, t]) + + return ( + <> + {showRemoveWalletModal && ( + } + modalName={ModalName.RemoveWallet} + severity={WarningSeverity.High} + title={t('account.wallet.remove.title', { name: displayName?.name ?? '' })} + onClose={() => setShowRemoveWalletModal(false)} + onConfirm={onRemoveWallet} + /> + )} + {showEditLabelModal && setShowEditLabelModal(false)} />} + + + + + + {loading || error ? '' : formattedBalance} + + + + + + + + + + + ) +} diff --git a/apps/extension/src/app/features/accounts/AccountSwitcherScreen.test.tsx b/apps/extension/src/app/features/accounts/AccountSwitcherScreen.test.tsx new file mode 100644 index 00000000000..8d6c2a71e55 --- /dev/null +++ b/apps/extension/src/app/features/accounts/AccountSwitcherScreen.test.tsx @@ -0,0 +1,26 @@ +import { AccountSwitcherScreen } from 'src/app/features/accounts/AccountSwitcherScreen' +import { preloadedExtensionState } from 'src/test/fixtures/redux' +import { cleanup, render } from 'src/test/test-utils' + +const preloadedState = preloadedExtensionState() + +const SAMPLE_DAPP = 'http://example.com' + +jest.mock('src/app/features/dapp/DappContext', () => { + const real = jest.requireActual('src/app/features/dapp/DappContext') + return { ...real, useDappContext: jest.fn(() => ({ dappUrl: SAMPLE_DAPP })) } +}) + +jest.mock('src/app/features/dapp/hooks', () => { + const { ACCOUNT, ACCOUNT3 } = require('wallet/src/test/fixtures') + return { useDappConnectedAccounts: jest.fn(() => [ACCOUNT, ACCOUNT3]) } +}) + +describe(AccountSwitcherScreen, () => { + it('renders correctly', async () => { + const tree = render(, { preloadedState }) + + expect(tree).toMatchSnapshot() + cleanup() + }) +}) diff --git a/apps/extension/src/app/features/accounts/AccountSwitcherScreen.tsx b/apps/extension/src/app/features/accounts/AccountSwitcherScreen.tsx new file mode 100644 index 00000000000..df1927578c0 --- /dev/null +++ b/apps/extension/src/app/features/accounts/AccountSwitcherScreen.tsx @@ -0,0 +1,275 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useSelector } from 'react-redux' +import { ComingSoon } from 'src/app/components/ComingSoon' +import { ScreenHeader } from 'src/app/components/layout/ScreenHeader' +import { AccountItem } from 'src/app/features/accounts/AccountItem' +import { CreateWalletModal } from 'src/app/features/accounts/CreateWalletModal' +import { EditLabelModal } from 'src/app/features/accounts/EditLabelModal' +import { useDappContext } from 'src/app/features/dapp/DappContext' +import { updateDappConnectedAddressFromExtension } from 'src/app/features/dapp/actions' +import { useDappConnectedAccounts } from 'src/app/features/dapp/hooks' +import { isConnectedAccount } from 'src/app/features/dapp/utils' +import { PopupName, openPopup } from 'src/app/features/popups/slice' +import { AppRoutes, RemoveRecoveryPhraseRoutes, SettingsRoutes } from 'src/app/navigation/constants' +import { navigate } from 'src/app/navigation/state' +import { useAppDispatch } from 'src/store/store' +import { Button, Flex, MenuContent, MenuContentItem, Popover, ScrollView, Text, useSporeColors } from 'ui/src' +import { WalletFilled, X } from 'ui/src/components/icons' +import { spacing } from 'ui/src/theme' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { ModalName, WalletEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' +import { ImportType } from 'uniswap/src/types/onboarding' +import { logger } from 'utilities/src/logger/logger' +import { sleep } from 'utilities/src/time/timing' +import { AddressDisplay } from 'wallet/src/components/accounts/AddressDisplay' +import { PlusCircle } from 'wallet/src/components/icons/PlusCircle' +import { WarningModal } from 'wallet/src/components/modals/WarningModal/WarningModal' +import { createOnboardingAccount } from 'wallet/src/features/onboarding/createOnboardingAccount' +import { WarningSeverity } from 'wallet/src/features/transactions/WarningModal/types' +import { AccountType, BackupType, SignerMnemonicAccount } from 'wallet/src/features/wallet/accounts/types' +import { createAccountsActions } from 'wallet/src/features/wallet/create/createAccountsSaga' +import { useActiveAccountWithThrow, useDisplayName, useSignerAccounts } from 'wallet/src/features/wallet/hooks' +import { selectSortedSignerMnemonicAccounts } from 'wallet/src/features/wallet/selectors' +import { setAccountAsActive } from 'wallet/src/features/wallet/slice' +import { DisplayNameType } from 'wallet/src/features/wallet/types' + +const MIN_MENU_WIDTH = 200 + +export function AccountSwitcherScreen(): JSX.Element { + const colors = useSporeColors() + const dispatch = useAppDispatch() + const { t } = useTranslation() + + const activeAccount = useActiveAccountWithThrow() + const activeAddress = activeAccount.address + const isViewOnly = activeAccount.type === AccountType.Readonly + + const accounts = useSignerAccounts() + const accountAddresses = useMemo( + () => accounts.map((account) => account.address).filter((address) => address !== activeAddress), + [accounts, activeAddress], + ) + const { dappUrl } = useDappContext() + + const connectedAccounts = useDappConnectedAccounts(dappUrl) + + // TODO: EXT-899 https://linear.app/uniswap/issue/EXT-899/enable-unitag-edit-button-is-account-header + const activeAccountDisplayName = useDisplayName(activeAddress) + const activeAccountHasUnitag = activeAccountDisplayName?.type === DisplayNameType.Unitag + + const [showEditLabelModal, setShowEditLabelModal] = useState(false) + + const [showRemoveWalletModal, setShowRemoveWalletModal] = useState(false) + const [showCreateWalletModal, setShowCreateWalletModal] = useState(false) + + const [pendingWallet, setPendingWallet] = useState() + + const sortedMnemonicAccounts = useSelector(selectSortedSignerMnemonicAccounts) + + useEffect(() => { + const createOnboardingAccountAfterTransitionAnimation = async (): Promise => { + // TODO: EXT-1164 - Move Keyring methods to workers to not block main thread during onboarding + // Delays computation heavy function invocation to avoid disrupting transition animation + await sleep(400) + setPendingWallet(await createOnboardingAccount(sortedMnemonicAccounts)) + } + createOnboardingAccountAfterTransitionAnimation().catch((e) => { + logger.error(e, { + tags: { file: 'AccountSwitcherScreen', function: 'createOnboardingAccount' }, + }) + }) + }, [sortedMnemonicAccounts]) + + const onNavigateToRemoveWallet = (): void => { + setShowRemoveWalletModal(false) + navigate(`/${AppRoutes.Settings}/${SettingsRoutes.RemoveRecoveryPhrase}/${RemoveRecoveryPhraseRoutes.Wallets}`) + } + + const onCancelCreateWallet = (): void => { + setShowCreateWalletModal(false) + } + + const onConfirmCreateWallet = useCallback( + async (walletLabel: string): Promise => { + setShowCreateWalletModal(false) + if (!pendingWallet) { + return + } + + if (walletLabel) { + pendingWallet.name = walletLabel + } + + dispatch( + createAccountsActions.trigger({ + accounts: [pendingWallet], + }), + ) + + sendAnalyticsEvent(WalletEventName.WalletAdded, { + wallet_type: ImportType.CreateAdditional, + accounts_imported_count: 1, + wallets_imported: [pendingWallet.address], + cloud_backup_used: pendingWallet.backups?.includes(BackupType.Cloud) ?? false, + modal: ModalName.AccountSwitcher, + }) + + navigate(-1) + + // Only show connect popup if some account is connected to current dapp + if (connectedAccounts.length > 0) { + dispatch(openPopup(PopupName.Connect)) + } + }, + [connectedAccounts.length, dispatch, pendingWallet], + ) + + const addWalletMenuOptions: MenuContentItem[] = [ + { + label: t('account.wallet.button.create'), + onPress: (): void => setShowCreateWalletModal(true), + }, + { + label: t('account.wallet.button.import'), + onPress: (): void => setShowRemoveWalletModal(true), + }, + ] + + const contentShadowProps = { + shadowColor: colors.shadowColor.val, + shadowRadius: 12, + shadowOpacity: 0.1, + zIndex: 1, + } + + return ( + + {showEditLabelModal && setShowEditLabelModal(false)} />} + {showRemoveWalletModal && ( + } + modalName={ModalName.RemoveWallet} + severity={WarningSeverity.High} + title={t('account.wallet.button.import')} + onClose={() => setShowRemoveWalletModal(false)} + onConfirm={onNavigateToRemoveWallet} + /> + )} + {showCreateWalletModal && ( + + )} + + + + + {activeAccountHasUnitag ? ( + + ) : ( + + )} + + + {accountAddresses.length > 0 && ( + + {t('account.wallet.header.other')} + + )} + + {accountAddresses.map((address: string) => { + return ( + => { + dispatch(setAccountAsActive(address)) + await updateDappConnectedAddressFromExtension(address) + if (connectedAccounts.length > 0 && !isConnectedAccount(connectedAccounts, address)) { + dispatch(openPopup(PopupName.Connect)) + } + navigate(-1) + }} + /> + ) + })} + + + + + + + {t('account.wallet.button.add')} + + + + + + + + + + + ) +} + +const UnitagActionButton = (): JSX.Element => { + const { t } = useTranslation() + return ( + + + + ) +} diff --git a/apps/extension/src/app/features/accounts/CreateWalletModal.tsx b/apps/extension/src/app/features/accounts/CreateWalletModal.tsx new file mode 100644 index 00000000000..2ce86ea5e32 --- /dev/null +++ b/apps/extension/src/app/features/accounts/CreateWalletModal.tsx @@ -0,0 +1,91 @@ +import { useCallback, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { OpaqueColorValue } from 'react-native' +import { Button, Flex, Text, getUniconColors, useIsDarkMode } from 'ui/src' +import { iconSizes, opacify } from 'ui/src/theme' +import { TextInput } from 'uniswap/src/components/input/TextInput' +import { BottomSheetModal } from 'uniswap/src/components/modals/BottomSheetModal' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { shortenAddress } from 'uniswap/src/utils/addresses' +import { AccountIcon } from 'wallet/src/components/accounts/AccountIcon' +import { SignerMnemonicAccount } from 'wallet/src/features/wallet/accounts/types' + +type CreateWalletModalProps = { + pendingWallet?: SignerMnemonicAccount + onCancel: () => void + onConfirm: (walletLabel: string) => void +} + +// Expects a pending account to be created before opening this modal +export function CreateWalletModal({ pendingWallet, onCancel, onConfirm }: CreateWalletModalProps): JSX.Element | null { + const { t } = useTranslation() + const isDark = useIsDarkMode() + + const [inputText, setInputText] = useState('') + + const nextDerivationIndex = pendingWallet?.derivationIndex + const onboardingAccountAddress = pendingWallet?.address + + const onPressConfirm = useCallback(() => { + onConfirm(inputText) + }, [inputText, onConfirm]) + + const placeholderText = nextDerivationIndex + ? t('account.wallet.create.placeholder', { index: nextDerivationIndex + 1 }) + : '' + + const { color: uniconColor } = onboardingAccountAddress + ? getUniconColors(onboardingAccountAddress, isDark) + : { color: '' } + + // Cast because Button component doesnt acccept sytling outside of theme color values for hover and press states + const hoverAndPressButtonStyle = useMemo(() => { + return { + backgroundColor: opacify(15, uniconColor) as unknown as OpaqueColorValue, + } + }, [uniconColor]) + + return ( + + + + {onboardingAccountAddress && } + + + + {onboardingAccountAddress && ( + + {shortenAddress(onboardingAccountAddress)} + + )} + + + + + + + + + ) +} diff --git a/apps/extension/src/app/features/accounts/EditLabelModal.tsx b/apps/extension/src/app/features/accounts/EditLabelModal.tsx new file mode 100644 index 00000000000..d7f29cb9915 --- /dev/null +++ b/apps/extension/src/app/features/accounts/EditLabelModal.tsx @@ -0,0 +1,74 @@ +import { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Button, Flex, Text } from 'ui/src' +import { iconSizes } from 'ui/src/theme' +import { TextInput } from 'uniswap/src/components/input/TextInput' +import { BottomSheetModal } from 'uniswap/src/components/modals/BottomSheetModal' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { shortenAddress } from 'utilities/src/addresses' +import { AccountIcon } from 'wallet/src/components/accounts/AccountIcon' +import { EditAccountAction, editAccountActions } from 'wallet/src/features/wallet/accounts/editAccountSaga' +import { useDisplayName } from 'wallet/src/features/wallet/hooks' +import { DisplayNameType } from 'wallet/src/features/wallet/types' +import { useAppDispatch } from 'wallet/src/state' + +type EditLabelModalProps = { + address: Address + onClose: () => void +} + +export function EditLabelModal({ address, onClose }: EditLabelModalProps): JSX.Element { + const { t } = useTranslation() + const dispatch = useAppDispatch() + + const displayName = useDisplayName(address) + const defaultText = displayName?.type === DisplayNameType.Local ? displayName.name : '' + + const [inputText, setInputText] = useState(defaultText) + const [isfocused, setIsFocused] = useState(false) + + const onConfirm = useCallback(async () => { + await dispatch( + editAccountActions.trigger({ + type: EditAccountAction.Rename, + address, + newName: inputText, + }), + ) + onClose() + }, [address, dispatch, inputText, onClose]) + + return ( + + + + + + setIsFocused(false)} + onChangeText={setInputText} + onFocus={() => setIsFocused(true)} + /> + + + {shortenAddress(address)} + + + + + + + + + ) +} diff --git a/apps/extension/src/app/features/accounts/__snapshots__/AccountSwitcherScreen.test.tsx.snap b/apps/extension/src/app/features/accounts/__snapshots__/AccountSwitcherScreen.test.tsx.snap new file mode 100644 index 00000000000..6f10c474ac9 --- /dev/null +++ b/apps/extension/src/app/features/accounts/__snapshots__/AccountSwitcherScreen.test.tsx.snap @@ -0,0 +1,463 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AccountSwitcherScreen renders correctly 1`] = ` +{ + "asFragment": [Function], + "baseElement": +
+ + +
+
+
+ + + +
+
+ + + +
+
+
+
+
+ + + + + + + + +
+
+
+
+ + Jacob Haley + +
+
+
+
+ + 0x​0fc6...be59 + + + + +
+
+
+
+ + + +
+ +
+
+
+ +
+
+ +
+
+ +
+ , + "container":
+ + +
+
+
+ + + +
+
+ + + +
+
+
+
+
+ + + + + + + + +
+
+
+
+ + Jacob Haley + +
+
+
+
+ + 0x​0fc6...be59 + + + + +
+
+
+
+ + + +
+ +
+
+
+ +
+
+ +
+
+ +
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "store": { + "@@observable": [Function], + "dispatch": [Function], + "getState": [Function], + "replaceReducer": [Function], + "subscribe": [Function], + }, + "unmount": [Function], +} +`; diff --git a/apps/extension/src/app/features/dapp/DappContext.tsx b/apps/extension/src/app/features/dapp/DappContext.tsx new file mode 100644 index 00000000000..25969714c8b --- /dev/null +++ b/apps/extension/src/app/features/dapp/DappContext.tsx @@ -0,0 +1,67 @@ +import { createContext, ReactNode, useContext, useEffect, useState } from 'react' +import { useDappConnectedAccounts, useDappLastChainId } from 'src/app/features/dapp/hooks' +import { isConnectedAccount } from 'src/app/features/dapp/utils' +import { extractBaseUrl } from 'src/app/features/dappRequests/utils' +import { closePopup, PopupName } from 'src/app/features/popups/slice' +import { backgroundToSidePanelMessageChannel } from 'src/background/messagePassing/messageChannels' +import { BackgroundToSidePanelRequestType } from 'src/background/messagePassing/types/requests' +import { useAppDispatch } from 'src/store/store' +import { WalletChainId } from 'uniswap/src/types/chains' +import { useActiveAccountAddress } from 'wallet/src/features/wallet/hooks' + +type DappContextState = { + dappUrl: string + dappIconUrl?: string + isConnected: boolean + lastChainId?: WalletChainId +} + +const DappContext = createContext(undefined) + +export function DappContextProvider({ children }: { children: ReactNode }): JSX.Element { + const [dappUrl, setDappUrl] = useState('') + const [dappIconUrl, setDappIconUrl] = useState(undefined) + + const activeAddress = useActiveAccountAddress() + const connectedAccounts = useDappConnectedAccounts(dappUrl) + const lastChainId = useDappLastChainId(dappUrl) + const dispatch = useAppDispatch() + + const isConnected = !!activeAddress && isConnectedAccount(connectedAccounts, activeAddress) + + useEffect(() => { + const updateDappInfo = (): void => { + chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { + const tab = tabs[0] + if (tab) { + setDappUrl(extractBaseUrl(tab?.url) || '') + setDappIconUrl(tab.favIconUrl) + } + }) + } + + updateDappInfo() + + return backgroundToSidePanelMessageChannel.addMessageListener( + BackgroundToSidePanelRequestType.TabActivated, + async (_message) => { + updateDappInfo() + dispatch(closePopup(PopupName.Connect)) + }, + ) + }, [setDappIconUrl, setDappUrl, dispatch]) + + const value = { dappUrl, dappIconUrl, isConnected, lastChainId } + + return {children} +} + +export function useDappContext(): DappContextState { + const context = useContext(DappContext) + + if (context === undefined) { + throw new Error('useDappContext must be used within a DappContextProvider') + } + + return context +} diff --git a/apps/extension/src/app/features/dapp/actions.ts b/apps/extension/src/app/features/dapp/actions.ts new file mode 100644 index 00000000000..a7852020b95 --- /dev/null +++ b/apps/extension/src/app/features/dapp/actions.ts @@ -0,0 +1,71 @@ +import { dappStore } from 'src/app/features/dapp/store' +import { externalDappMessageChannel } from 'src/background/messagePassing/messageChannels' +import { + ExtensionChainChange, + ExtensionToDappRequestType, + UpdateConnectionRequest, +} from 'src/background/messagePassing/types/requests' +import { chainIdToHexadecimalString } from 'uniswap/src/features/chains/utils' +import { WalletChainId } from 'uniswap/src/types/chains' +import { Account } from 'wallet/src/features/wallet/accounts/types' +import { getProviderSync } from 'wallet/src/features/wallet/context' + +export async function saveDappChain(dappUrl: string, chainId: WalletChainId): Promise { + dappStore.updateDappLatestChainId(dappUrl, chainId) + const provider = getProviderSync(chainId) + + const response: ExtensionChainChange = { + type: ExtensionToDappRequestType.SwitchChain, + providerUrl: provider.connection.url, + chainId: chainIdToHexadecimalString(chainId), + } + + await externalDappMessageChannel.sendMessageToTabUrl(dappUrl, response) +} + +export async function saveDappConnection(dappUrl: string, account: Account): Promise { + dappStore.saveDappActiveAccount(dappUrl, account) + await updateConnectionFromExtension(dappUrl) +} + +export async function removeDappConnection(dappUrl: string, account?: Account): Promise { + dappStore.removeDappConnection(dappUrl, account) + await updateConnectionFromExtension(dappUrl) +} + +async function updateConnectionFromExtension(dappUrl: string): Promise { + const connectedWallets = dappStore.getDappOrderedConnectedAddresses(dappUrl) ?? [] + const response: UpdateConnectionRequest = { + type: ExtensionToDappRequestType.UpdateConnections, + addresses: connectedWallets, + } + + await externalDappMessageChannel.sendMessageToTabUrl(dappUrl, response) +} + +export async function updateDappConnectedAddressFromExtension(address: Address): Promise { + dappStore.updateDappConnectedAddress(address) + const connectedDapps = dappStore.getConnectedDapps(address) + for (const dappUrl of connectedDapps) { + await updateConnectionFromExtension(dappUrl) + } +} + +export async function removeAllDappConnectionsForAccount(account: Account): Promise { + const connectedDapps = dappStore.getConnectedDapps(account.address) + for (const dappUrl of connectedDapps) { + await removeDappConnection(dappUrl, account) + } +} + +export async function removeAllDappConnectionsFromExtension(): Promise { + const dappUrls = dappStore.getDappUrls() + for (const dappUrl of dappUrls) { + const response: UpdateConnectionRequest = { + type: ExtensionToDappRequestType.UpdateConnections, + addresses: [], + } + await externalDappMessageChannel.sendMessageToTabUrl(dappUrl, response) + } + dappStore.removeAllDappConnections() +} diff --git a/apps/extension/src/app/features/dapp/changeChain.test.ts b/apps/extension/src/app/features/dapp/changeChain.test.ts new file mode 100644 index 00000000000..6d2d2b4a36b --- /dev/null +++ b/apps/extension/src/app/features/dapp/changeChain.test.ts @@ -0,0 +1,115 @@ +import { JsonRpcProvider } from '@ethersproject/providers' +import { providerErrors, serializeError } from '@metamask/rpc-errors' +import { changeChain } from 'src/app/features/dapp/changeChain' +import { dappStore } from 'src/app/features/dapp/store' +import { DappResponseType } from 'src/app/features/dappRequests/types/DappRequestTypes' +import { chainIdToHexadecimalString } from 'uniswap/src/features/chains/utils' +import { ExtensionEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { WalletChainId } from 'uniswap/src/types/chains' + +// Mock dependencies +jest.mock('@ethersproject/providers') +jest.mock('@metamask/rpc-errors') +jest.mock('src/app/features/dapp/store') +jest.mock('uniswap/src/features/telemetry/send') +jest.mock('uniswap/src/features/chains/utils') + +describe('changeChain', () => { + const mockRequestId = 'test-request-id' + const mockProviderUrl = 'http://localhost:8545' + const mockChainId = 1 as WalletChainId + + let mockProvider: JsonRpcProvider + + beforeEach(() => { + jest.clearAllMocks() + + mockProvider = { + connection: { + url: mockProviderUrl, + }, + } as JsonRpcProvider + }) + + it('should return an error response if updatedChainId is null', () => { + const response = changeChain({ + activeConnectedAddress: undefined, + dappUrl: undefined, + provider: mockProvider, + requestId: mockRequestId, + updatedChainId: null, + }) + + expect(response).toEqual({ + type: DappResponseType.ErrorResponse, + error: serializeError( + providerErrors.custom({ + code: 4902, + message: 'Uniswap Wallet does not support switching to this chain.', + }) + ), + requestId: mockRequestId, + }) + }) + + it('should return an error response if provider is null', () => { + const response = changeChain({ + activeConnectedAddress: undefined, + dappUrl: undefined, + provider: null, + requestId: mockRequestId, + updatedChainId: mockChainId, + }) + + expect(response).toEqual({ + type: DappResponseType.ErrorResponse, + error: serializeError(providerErrors.unauthorized()), + requestId: mockRequestId, + }) + }) + + it('should update dappStore and send analytics event if dappUrl is provided', () => { + const mockDappUrl = 'http://example.com' + + const response = changeChain({ + activeConnectedAddress: '0xAddress', + dappUrl: mockDappUrl, + provider: mockProvider, + requestId: mockRequestId, + updatedChainId: mockChainId, + }) + + expect(dappStore.updateDappLatestChainId).toHaveBeenCalledWith(mockDappUrl, mockChainId) + expect(sendAnalyticsEvent).toHaveBeenCalledWith(ExtensionEventName.DappChangeChain, { + dappUrl: mockDappUrl, + chainId: mockChainId, + activeConnectedAddress: '0xAddress', + }) + + expect(response).toEqual({ + type: DappResponseType.ChainChangeResponse, + requestId: mockRequestId, + providerUrl: mockProviderUrl, + chainId: chainIdToHexadecimalString(mockChainId), + }) + }) + + it('should not update dappStore if dappUrl is not provided', () => { + const response = changeChain({ + activeConnectedAddress: '0xAddress', + dappUrl: undefined, + provider: mockProvider, + requestId: mockRequestId, + updatedChainId: mockChainId, + }) + + expect(dappStore.updateDappLatestChainId).not.toHaveBeenCalled() + + expect(response).toEqual({ + type: DappResponseType.ErrorResponse, + error: serializeError(providerErrors.unauthorized()), + requestId: mockRequestId, + }) + }) +}) diff --git a/apps/extension/src/app/features/dapp/changeChain.ts b/apps/extension/src/app/features/dapp/changeChain.ts new file mode 100644 index 00000000000..a7a14a0408a --- /dev/null +++ b/apps/extension/src/app/features/dapp/changeChain.ts @@ -0,0 +1,69 @@ +import { JsonRpcProvider } from '@ethersproject/providers' +import { providerErrors, serializeError } from '@metamask/rpc-errors' +import { dappStore } from 'src/app/features/dapp/store' +import { + ChangeChainResponse, + DappResponseType, + ErrorResponse, +} from 'src/app/features/dappRequests/types/DappRequestTypes' +import { chainIdToHexadecimalString } from 'uniswap/src/features/chains/utils' +import { ExtensionEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { WalletChainId } from 'uniswap/src/types/chains' + +export function changeChain({ + activeConnectedAddress, + dappUrl, + provider, + requestId, + updatedChainId, +}: { + activeConnectedAddress: Address | undefined + dappUrl: string | undefined + provider: JsonRpcProvider | undefined | null + requestId: string + updatedChainId: WalletChainId | null +}): ChangeChainResponse | ErrorResponse { + if (!updatedChainId) { + return { + type: DappResponseType.ErrorResponse, + error: serializeError( + providerErrors.custom({ + code: 4902, + message: 'Uniswap Wallet does not support switching to this chain.', + }), + ), + requestId, + } + } + + if (!provider) { + return { + type: DappResponseType.ErrorResponse, + error: serializeError(providerErrors.unauthorized()), + requestId, + } + } + + if (dappUrl) { + dappStore.updateDappLatestChainId(dappUrl, updatedChainId) + sendAnalyticsEvent(ExtensionEventName.DappChangeChain, { + dappUrl: dappUrl ?? '', + chainId: updatedChainId, + activeConnectedAddress: activeConnectedAddress ?? '', + }) + + return { + type: DappResponseType.ChainChangeResponse, + requestId, + providerUrl: provider.connection.url, + chainId: chainIdToHexadecimalString(updatedChainId), + } + } + + return { + type: DappResponseType.ErrorResponse, + error: serializeError(providerErrors.unauthorized()), + requestId, + } +} diff --git a/apps/extension/src/app/features/dapp/hooks.test.ts b/apps/extension/src/app/features/dapp/hooks.test.ts new file mode 100644 index 00000000000..1b5f1f72a6e --- /dev/null +++ b/apps/extension/src/app/features/dapp/hooks.test.ts @@ -0,0 +1,104 @@ +import { + useDappConnectedAccounts, + useDappInfo, + useDappLastChainId, + useDappStateUpdated, +} from 'src/app/features/dapp/hooks' +import { DappState, dappStore } from 'src/app/features/dapp/store' +import { act, renderHook, waitFor } from 'src/test/test-utils' +import { UniverseChainId } from 'uniswap/src/types/chains' +import { + ACCOUNT, + ACCOUNT2, + ACCOUNT3, + SAMPLE_SEED_ADDRESS_1, + SAMPLE_SEED_ADDRESS_3, +} from 'wallet/src/test/fixtures' + +const SAMPLE_DAPP = 'http://example.com' +const SAMPLE_DAPP_2 = 'http://uniswap.org' + +const dappState: DappState = { + [SAMPLE_DAPP]: { + lastChainId: UniverseChainId.ArbitrumOne, + connectedAccounts: [ACCOUNT, ACCOUNT2], + activeConnectedAddress: SAMPLE_SEED_ADDRESS_1, + }, + [SAMPLE_DAPP_2]: { + lastChainId: UniverseChainId.Base, + connectedAccounts: [ACCOUNT, ACCOUNT3], + activeConnectedAddress: SAMPLE_SEED_ADDRESS_3, + }, +} + +const mockAddListener = jest.fn() +const mockGet = jest.fn(() => { + return Promise.resolve({ dappState }) +}) +Object.defineProperty(global, 'chrome', { + value: { + runtime: { lastError: undefined }, + storage: { + local: { + get: mockGet, + set: jest.fn(), + onChanged: { + addListener: mockAddListener, + }, + }, + }, + }, +}) + +describe('Dapp hooks', () => { + let onChangeListener: (changes: { dappState: chrome.storage.StorageChange }) => void + beforeAll(async () => { + await dappStore.init() + onChangeListener = mockAddListener.mock.calls[0][0] + }) + + it('useDappStateUpdated should update state when chrome storage changes', () => { + const { result } = renderHook(() => useDappStateUpdated()) + expect(result.current).toBe(false) + act(() => { + onChangeListener({ dappState: { newValue: dappState } }) + }) + expect(result.current).toBe(true) + }) + + it('useDappInfo should return undefined when dappUrl is undefined', async () => { + const { result } = renderHook(() => useDappInfo(undefined)) + await waitFor(() => expect(result.current).toBeUndefined()) + }) + + it('useDappInfo should return DappInfo when dappUrl is defined', async () => { + const { result } = renderHook(() => useDappInfo(SAMPLE_DAPP)) + await waitFor(() => + expect(result.current).toEqual({ + lastChainId: UniverseChainId.ArbitrumOne, + connectedAccounts: [ACCOUNT, ACCOUNT2], + activeConnectedAddress: SAMPLE_SEED_ADDRESS_1, + }) + ) + }) + + it('useDappLastChainId should return undefined when dappUrl is undefined', async () => { + const { result } = renderHook(() => useDappLastChainId(undefined)) + await waitFor(() => expect(result.current).toBeUndefined()) + }) + + it('useDappLastChainId should return lastChainId when dappUrl is defined', async () => { + const { result } = renderHook(() => useDappLastChainId(SAMPLE_DAPP_2)) + await waitFor(() => expect(result.current).toBe(UniverseChainId.Base)) + }) + + it('useDappConnectedAccounts should return empty array when dappUrl is undefined', async () => { + const { result } = renderHook(() => useDappConnectedAccounts(undefined)) + await waitFor(() => expect(result.current).toEqual([])) + }) + + it('useDappConnectedAccounts should return connected accounts when dappUrl is defined', async () => { + const { result } = renderHook(() => useDappConnectedAccounts(SAMPLE_DAPP)) + await waitFor(() => expect(result.current).toEqual([ACCOUNT, ACCOUNT2])) + }) +}) diff --git a/apps/extension/src/app/features/dapp/hooks.ts b/apps/extension/src/app/features/dapp/hooks.ts new file mode 100644 index 00000000000..ae5b5a9530d --- /dev/null +++ b/apps/extension/src/app/features/dapp/hooks.ts @@ -0,0 +1,34 @@ +import { useEffect, useReducer, useState } from 'react' +import { DappInfo, DappStoreEvent, dappStore } from 'src/app/features/dapp/store' +import { WalletChainId } from 'uniswap/src/types/chains' +import { Account } from 'wallet/src/features/wallet/accounts/types' + +// exported to be used in tests +export function useDappStateUpdated(): boolean { + const [state, dispatch] = useReducer((v) => !v, false) + useEffect(() => { + const onUpdate = (): void => dispatch() + dappStore.addListener(DappStoreEvent.DappStateUpdated, onUpdate) + return () => { + dappStore.removeListener(DappStoreEvent.DappStateUpdated, onUpdate) + } + }, [dispatch]) + return state +} + +export function useDappInfo(dappUrl: string | undefined): DappInfo | undefined { + const [info, setInfo] = useState() + const dappStateUpdated = useDappStateUpdated() + useEffect(() => { + setInfo(dappStore.getDappInfo(dappUrl)) + }, [dappUrl, dappStateUpdated]) + return info +} + +export function useDappLastChainId(dappUrl: string | undefined): WalletChainId | undefined { + return useDappInfo(dappUrl)?.lastChainId +} + +export function useDappConnectedAccounts(dappUrl: string | undefined): Account[] { + return useDappInfo(dappUrl)?.connectedAccounts || [] +} diff --git a/apps/extension/src/app/features/dapp/saga.ts b/apps/extension/src/app/features/dapp/saga.ts new file mode 100644 index 00000000000..b2494f85492 --- /dev/null +++ b/apps/extension/src/app/features/dapp/saga.ts @@ -0,0 +1,9 @@ +import { dappStore } from 'src/app/features/dapp/store' +import { call } from 'typed-redux-saga' +import { logger } from 'utilities/src/logger/logger' + +// Initialize Dapp Store +export function* initDappStore() { + logger.debug('dappStoreSaga', 'initDappStore', 'Initializing Dapp Store') + yield* call(dappStore.init) +} diff --git a/apps/extension/src/app/features/dapp/store.ts b/apps/extension/src/app/features/dapp/store.ts new file mode 100644 index 00000000000..2fafa6a3173 --- /dev/null +++ b/apps/extension/src/app/features/dapp/store.ts @@ -0,0 +1,199 @@ +import EventEmitter from 'eventemitter3' +import { getOrderedConnectedAddresses, isConnectedAccount } from 'src/app/features/dapp/utils' +import { UniverseChainId, WalletChainId } from 'uniswap/src/types/chains' +import { Account } from 'wallet/src/features/wallet/accounts/types' + +const STATE_STORAGE_KEY = 'dappState' + +export interface DappInfo { + lastChainId: WalletChainId + connectedAccounts: Account[] + activeConnectedAddress: Address +} + +export interface DappState { + [dappUrl: string]: DappInfo +} + +const initialDappState: DappState = {} +let state: DappState + +// Event Emitter +export enum DappStoreEvent { + DappStateUpdated = 'DappStateUpdated', +} + +class DappStoreEventEmitter extends EventEmitter {} +const dappStoreEventEmitter = new DappStoreEventEmitter() + +// Init +let initPromise: Promise | undefined + +async function init(): Promise { + if (!initPromise) { + initPromise = initInternal() + } + + return initPromise +} + +async function initInternal(): Promise { + state = (await chrome.storage.local.get([STATE_STORAGE_KEY]))?.[STATE_STORAGE_KEY] || initialDappState + + chrome.storage.local.onChanged.addListener((changes) => { + if (changes.dappState) { + state = changes.dappState.newValue + dappStoreEventEmitter.emit(DappStoreEvent.DappStateUpdated, state) + } + }) +} + +// Sequential syncing of state to local storage +let dappStateSyncPromise = Promise.resolve() +let dappStateChangesNeedSync = false +function queueDappStateSync(): void { + if (!dappStateChangesNeedSync) { + dappStateChangesNeedSync = true + dappStateSyncPromise = dappStateSyncPromise.then((): Promise => { + dappStateChangesNeedSync = false + return chrome.storage.local.set({ [STATE_STORAGE_KEY]: state }) + }) + } +} + +/** Returns all dapp URLs that are connected to a particular address. */ +function getConnectedDapps(address: Address): string[] { + return Object.entries(state) + .filter(([_, dappInfo]) => isConnectedAccount(dappInfo.connectedAccounts, address)) + .map(([dappUrl]) => dappUrl) +} + +/** Returns connected addresses with the currently connected address listed first. */ +function getDappOrderedConnectedAddresses(dappUrl: string): string[] | undefined { + const dappInfo = state[dappUrl] + if (!dappInfo) { + return undefined + } + const { connectedAccounts, activeConnectedAddress } = dappInfo + return getOrderedConnectedAddresses(connectedAccounts, activeConnectedAddress) +} + +function getDappInfo(dappUrl: string | undefined): DappInfo | undefined { + return dappUrl ? state[dappUrl] : undefined +} + +function getDappInfoIfConnected(dappUrl: string | undefined): DappInfo | undefined { + const dappInfo = getDappInfo(dappUrl) + return dappInfo && dappInfo.connectedAccounts.length > 0 ? dappInfo : undefined +} + +function getDappUrls(): string[] { + return Object.keys(state) +} + +// Update the connected address for all dapps +function updateDappConnectedAddress(address: Address): void { + // Never directly mutate state, as some of its fields could have `writable: false` + state = Object.fromEntries( + Object.entries(state).map(([key, dappUrlState]) => { + if (isConnectedAccount(dappUrlState.connectedAccounts, address)) { + return [key, { ...dappUrlState, activeConnectedAddress: address }] + } + return [key, dappUrlState] + }), + ) + queueDappStateSync() +} + +function updateDappLatestChainId(dappUrl: string, chainId: WalletChainId): void { + // Never directly mutate state, as some of its fields could have `writable: false` + state = Object.fromEntries( + Object.entries(state).map(([key, dappUrlState]) => { + if (key === dappUrl) { + return [key, { ...dappUrlState, lastChainId: chainId }] + } + return [key, dappUrlState] + }), + ) + queueDappStateSync() +} + +function saveDappActiveAccount(dappUrl: string, account: Account): void { + // Never directly mutate state, as some of its fields could have `writable: false` + state = { + ...state, + [dappUrl]: { + lastChainId: state[dappUrl]?.lastChainId ?? UniverseChainId.Mainnet, + activeConnectedAddress: account.address, + connectedAccounts: ((): Account[] => { + const currConnectedAccounts = state[dappUrl]?.connectedAccounts || [] + const isConnectionNew = !isConnectedAccount(currConnectedAccounts, account.address) + + if (isConnectionNew) { + return [...currConnectedAccounts, account] + } + return currConnectedAccounts + })(), + }, + } + queueDappStateSync() +} + +/** + * Remove a dapp connection + * @param dappUrl extracted url for dapp + * @param account target account to remove connection. If undefined, will remove all accounts + * @returns + */ +function removeDappConnection(dappUrl: string, account?: Account): void { + // Never directly mutate state, as some of its fields could have `writable: false` + state = ((): DappState => { + const dappUrlState = state[dappUrl] + + if (!dappUrlState) { + return state + } + + const updatedAccounts = account + ? dappUrlState.connectedAccounts?.filter((existingAccount) => existingAccount.address !== account.address) + : [] + + const activeConnected = updatedAccounts[0] + if (activeConnected) { + return { + ...state, + [dappUrl]: { + ...dappUrlState, + connectedAccounts: updatedAccounts, + activeConnectedAddress: activeConnected.address, + }, + } + } else { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { [dappUrl]: _, ...restState } = state + return restState + } + })() + queueDappStateSync() +} + +function removeAllDappConnections(): void { + state = {} + queueDappStateSync() +} + +export const dappStore = { + getConnectedDapps, + getDappInfo, + getDappInfoIfConnected, + getDappOrderedConnectedAddresses, + getDappUrls, + init, + removeAllDappConnections, + removeDappConnection, + saveDappActiveAccount, + addListener: dappStoreEventEmitter.addListener.bind(dappStoreEventEmitter), + removeListener: dappStoreEventEmitter.removeListener.bind(dappStoreEventEmitter), + updateDappConnectedAddress, + updateDappLatestChainId, +} diff --git a/apps/extension/src/app/features/dapp/utils.test.ts b/apps/extension/src/app/features/dapp/utils.test.ts new file mode 100644 index 00000000000..35d4e569039 --- /dev/null +++ b/apps/extension/src/app/features/dapp/utils.test.ts @@ -0,0 +1,66 @@ +import { + getActiveConnectedAccount, + getOrderedConnectedAddresses, + isConnectedAccount, +} from 'src/app/features/dapp/utils' +import { Account } from 'wallet/src/features/wallet/accounts/types' +import { + ACCOUNT, + ACCOUNT2, + ACCOUNT3, + SAMPLE_SEED_ADDRESS_1, + SAMPLE_SEED_ADDRESS_2, + SAMPLE_SEED_ADDRESS_3, +} from 'wallet/src/test/fixtures' + +describe('isConnectedAccount', () => { + it('returns true if the account is connected', () => { + const accounts: Account[] = [ACCOUNT, ACCOUNT2] + expect(isConnectedAccount(accounts, SAMPLE_SEED_ADDRESS_1)).toBe(true) + }) + + it('returns false if the account is not connected', () => { + const accounts: Account[] = [ACCOUNT] + expect(isConnectedAccount(accounts, SAMPLE_SEED_ADDRESS_2)).toBe(false) + }) +}) + +describe('getActiveConnectedAccount', () => { + const accounts: Account[] = [ACCOUNT, ACCOUNT2] + + it('returns the account for the given address', () => { + const result = getActiveConnectedAccount(accounts, SAMPLE_SEED_ADDRESS_2) + expect(result).toEqual(ACCOUNT2) + }) + + it('throws an error if the address is not in the list', () => { + expect(() => { + getActiveConnectedAccount(accounts, SAMPLE_SEED_ADDRESS_3) + }).toThrow('The activeConnectedAddress must be in the list of connectedAccounts.') + }) +}) + +describe('getOrderedConnectedAddresses', () => { + const accounts: Account[] = [ACCOUNT, ACCOUNT2, ACCOUNT3] + + it('places the active address first', () => { + const activeAddress = SAMPLE_SEED_ADDRESS_2 + const expectedOrder = [SAMPLE_SEED_ADDRESS_2, SAMPLE_SEED_ADDRESS_1, SAMPLE_SEED_ADDRESS_3] + const result = getOrderedConnectedAddresses(accounts, activeAddress) + expect(result).toEqual(expectedOrder) + }) + + it('returns the same order if the active address is already first', () => { + const activeAddress = SAMPLE_SEED_ADDRESS_1 + const expectedOrder = [SAMPLE_SEED_ADDRESS_1, SAMPLE_SEED_ADDRESS_2, SAMPLE_SEED_ADDRESS_3] + const result = getOrderedConnectedAddresses(accounts, activeAddress) + expect(result).toEqual(expectedOrder) + }) + + it('handles cases where the active address is not in the list', () => { + const activeAddress = '0xabc' // Not in the accounts list + const expectedOrder = [SAMPLE_SEED_ADDRESS_1, SAMPLE_SEED_ADDRESS_2, SAMPLE_SEED_ADDRESS_3] // Original order since active address is not found + const result = getOrderedConnectedAddresses(accounts, activeAddress) + expect(result).toEqual(expectedOrder) + }) +}) diff --git a/apps/extension/src/app/features/dapp/utils.ts b/apps/extension/src/app/features/dapp/utils.ts new file mode 100644 index 00000000000..094d617a214 --- /dev/null +++ b/apps/extension/src/app/features/dapp/utils.ts @@ -0,0 +1,21 @@ +import { bubbleToTop } from 'utilities/src/primitives/array' +import { Account } from 'wallet/src/features/wallet/accounts/types' + +export function isConnectedAccount(connectedAccounts: Account[], address: Address): boolean { + return connectedAccounts.some((account) => account.address === address) +} + +/** Gets the Account for a specific address. The address param must be in the list of connectedAccounts. */ +export function getActiveConnectedAccount(connectedAccounts: Account[], activeConnectedAddress: Address): Account { + const activeConnectedAccount = connectedAccounts.find((account) => account.address === activeConnectedAddress) + if (!activeConnectedAccount) { + throw new Error('The activeConnectedAddress must be in the list of connectedAccounts.') + } + return activeConnectedAccount +} + +/** Returns all connected addresses with the currently connected address listed first. */ +export function getOrderedConnectedAddresses(connectedAccounts: Account[], activeConnectedAddress: Address): Address[] { + const connectedAddresses = connectedAccounts.map((account) => account.address) + return bubbleToTop(connectedAddresses, (address) => address === activeConnectedAddress) +} diff --git a/apps/extension/src/app/features/dappRequests/DappRequestContent.tsx b/apps/extension/src/app/features/dappRequests/DappRequestContent.tsx new file mode 100644 index 00000000000..5cf80a45725 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/DappRequestContent.tsx @@ -0,0 +1,425 @@ +import { PropsWithChildren, memo, useCallback } from 'react' +import { Trans, useTranslation } from 'react-i18next' +import { useDappLastChainId } from 'src/app/features/dapp/hooks' +import { useDappRequestQueueContext } from 'src/app/features/dappRequests/DappRequestQueueContext' +import { ConnectionRequestContent } from 'src/app/features/dappRequests/requestContent/Connection/ConnectionRequestContent' +import { EthSendRequestContent } from 'src/app/features/dappRequests/requestContent/EthSend/EthSend' +import { NetworksFooter } from 'src/app/features/dappRequests/requestContent/NetworksFooter' +import { PersonalSignRequestContent } from 'src/app/features/dappRequests/requestContent/PersonalSign/PersonalSignRequestContent' +import { SignTypedDataRequestContent } from 'src/app/features/dappRequests/requestContent/SignTypeData/SignTypedDataRequestContent' +import { rejectAllRequests } from 'src/app/features/dappRequests/saga' +import { DappRequestStoreItem } from 'src/app/features/dappRequests/slice' +import { + isDappRequestStoreItemForEthSendTxn, + isGetAccountRequest, + isRequestAccountRequest, + isRequestPermissionsRequest, + isSignMessageRequest, + isSignTypedDataRequest, +} from 'src/app/features/dappRequests/types/DappRequestTypes' +import { useAppDispatch } from 'src/store/store' +import { + Anchor, + AnimatePresence, + Button, + Flex, + Text, + TouchableArea, + UniversalImage, + UniversalImageResizeMode, + styled, + useSporeColors, +} from 'ui/src' +import { ReceiptText, RotatableChevron } from 'ui/src/components/icons' +import { iconSizes, zIndices } from 'ui/src/theme' +import { BottomSheetModal } from 'uniswap/src/components/modals/BottomSheetModal' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { UniverseChainId, WalletChainId } from 'uniswap/src/types/chains' +import { formatDappURL } from 'utilities/src/format/urls' +import { logger } from 'utilities/src/logger/logger' +import { DappIconPlaceholder } from 'wallet/src/components/WalletConnect/DappIconPlaceholder' +import { useUSDValue } from 'wallet/src/features/gas/hooks' +import { GasFeeResult } from 'wallet/src/features/gas/types' +import { AddressFooter } from 'wallet/src/features/transactions/TransactionRequest/AddressFooter' +import { NetworkFeeFooter } from 'wallet/src/features/transactions/TransactionRequest/NetworkFeeFooter' +import { TransactionTypeInfo } from 'wallet/src/features/transactions/types' +import { useActiveAccountWithThrow } from 'wallet/src/features/wallet/hooks' + +interface DappRequestHeaderProps { + title: string + headerIcon?: JSX.Element +} + +interface DappRequestFooterProps { + chainId?: WalletChainId + connectedAccountAddress?: string + confirmText: string + maybeCloseOnConfirm?: boolean + onCancel?: (requestToConfirm?: DappRequestStoreItem, transactionTypeInfo?: TransactionTypeInfo) => void + onConfirm?: (requestToCancel?: DappRequestStoreItem) => void + showAllNetworks?: boolean + showNetworkCost?: boolean + transactionGasFeeResult?: GasFeeResult +} + +type DappRequestContentProps = DappRequestHeaderProps & DappRequestFooterProps + +const REJECT_MESSAGE_HEIGHT = 48 + +const AnimatedPane = styled(Flex, { + variants: { + forwards: (dir: boolean) => ({ + enterStyle: { + x: dir ? 10 : -10, + opacity: 0, + }, + }), + increasing: (dir: boolean) => ({ + enterStyle: dir + ? { + y: 10, + opacity: 0, + } + : undefined, + exitStyle: !dir + ? { + y: 10, + opacity: 0, + } + : undefined, + }), + } as const, +}) + +export function DappRequestWrapper(): JSX.Element { + const { t } = useTranslation() + const colors = useSporeColors() + const dispatch = useAppDispatch() + + const { totalRequestCount, onPressPrevious, onPressNext, currentIndex, increasing } = useDappRequestQueueContext() + + const disabledPrevious = currentIndex <= 0 + const disabledNext = currentIndex >= totalRequestCount - 1 + + const onRejectAll = async (): Promise => { + dispatch(rejectAllRequests()) + } + + return ( + + + + {totalRequestCount > 1 && ( + + + + + + ), + }} + i18nKey="dapp.request.reject.info" + values={{ totalRequestCount }} + /> + + + + + {t('dapp.request.reject.action')} + + + + )} + + 1 ? REJECT_MESSAGE_HEIGHT + 12 : 0} + > + {totalRequestCount > 1 && ( + + + + + + {currentIndex + 1} + + + / + + + + + {totalRequestCount} + + + + + + + + )} + + + + + ) +} + +const DappRequest = memo(function _DappRequest(): JSX.Element { + const { t } = useTranslation() + const { request } = useDappRequestQueueContext() + + if (request) { + if (isSignMessageRequest(request.dappRequest)) { + return + } + if (isSignTypedDataRequest(request.dappRequest)) { + return + } + if (isDappRequestStoreItemForEthSendTxn(request)) { + return + } + if ( + isGetAccountRequest(request.dappRequest) || + isRequestAccountRequest(request.dappRequest) || + isRequestPermissionsRequest(request.dappRequest) + ) { + return + } + } + + return +}) + +export function DappRequestContent({ + chainId, + title, + headerIcon, + confirmText, + connectedAccountAddress, + maybeCloseOnConfirm, + onCancel, + onConfirm, + showAllNetworks, + showNetworkCost, + transactionGasFeeResult, + children, +}: PropsWithChildren): JSX.Element { + const { forwards, currentIndex } = useDappRequestQueueContext() + + return ( + <> + + + + {children} + + + + + ) +} + +function DappRequestHeader({ headerIcon, title }: DappRequestHeaderProps): JSX.Element { + const { dappIconUrl, dappUrl } = useDappRequestQueueContext() + const hostname = new URL(dappUrl).hostname.toUpperCase() + const fallbackIcon = + + return ( + + + + {headerIcon || ( + + )} + + + + {title} + + + + {formatDappURL(dappUrl)} + + + + ) +} + +const WINDOW_CLOSE_DELAY = 10 + +export function DappRequestFooter({ + chainId, + connectedAccountAddress, + confirmText, + maybeCloseOnConfirm, + onCancel, + onConfirm, + showAllNetworks, + showNetworkCost, + transactionGasFeeResult, +}: DappRequestFooterProps): JSX.Element { + const { t } = useTranslation() + const activeAccount = useActiveAccountWithThrow() + const { + dappUrl, + currentAccount, + request, + totalRequestCount, + onConfirm: defaultOnConfirm, + onCancel: defaultOnCancel, + } = useDappRequestQueueContext() + + const activeChain = useDappLastChainId(dappUrl) + + if (!request) { + const error = new Error('no request present') + logger.error(error, { tags: { file: 'DappRequestContent', function: 'DappRequestFooter' } }) + throw error + } + + const currentChainId = chainId || activeChain || UniverseChainId.Mainnet + const gasFeeUSD = useUSDValue(currentChainId, transactionGasFeeResult?.value) + + const shouldCloseSidebar = request.isSidebarClosed && totalRequestCount <= 1 + + const handleOnConfirm = useCallback(async () => { + if (onConfirm) { + onConfirm() + } else { + await defaultOnConfirm(request) + } + + if (maybeCloseOnConfirm && shouldCloseSidebar) { + setTimeout(window.close, WINDOW_CLOSE_DELAY) + } + }, [request, maybeCloseOnConfirm, onConfirm, defaultOnConfirm, shouldCloseSidebar]) + + const handleOnCancel = useCallback(async () => { + if (onCancel) { + onCancel() + } else { + await defaultOnCancel(request) + } + + if (shouldCloseSidebar) { + setTimeout(window.close, WINDOW_CLOSE_DELAY) + } + }, [request, onCancel, defaultOnCancel, shouldCloseSidebar]) + + return ( + <> + + {showNetworkCost && ( + + )} + {showAllNetworks && } + + + + + + + + ) +} diff --git a/apps/extension/src/app/features/dappRequests/DappRequestQueueContext.tsx b/apps/extension/src/app/features/dappRequests/DappRequestQueueContext.tsx new file mode 100644 index 00000000000..781cf2311f7 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/DappRequestQueueContext.tsx @@ -0,0 +1,144 @@ +import { providerErrors, serializeError } from '@metamask/rpc-errors' +import { PropsWithChildren, createContext, useContext, useEffect, useRef, useState } from 'react' +import { + confirmRequest, + confirmRequestNoDappInfo, + isDappRequestWithDappInfo, + rejectRequest, +} from 'src/app/features/dappRequests/saga' +import { DappRequestStoreItem } from 'src/app/features/dappRequests/slice' +import { DappResponseType } from 'src/app/features/dappRequests/types/DappRequestTypes' +import { extractBaseUrl } from 'src/app/features/dappRequests/utils' +import { useAppDispatch, useAppSelector } from 'src/store/store' +import { TransactionTypeInfo } from 'wallet/src/features/transactions/types' +import { Account } from 'wallet/src/features/wallet/accounts/types' +import { useActiveAccountWithThrow } from 'wallet/src/features/wallet/hooks' + +interface DappRequestQueueContextValue { + forwards: boolean // direction of sliding animation + increasing: boolean // direction of number increasing animation + request: DappRequestStoreItem | undefined + currentAccount: Account // Account the request is going to (not necessarily the active account) + dappUrl: string + dappIconUrl: string + currentIndex: number + totalRequestCount: number + onPressNext: () => void + onPressPrevious: () => void + onConfirm: (request: DappRequestStoreItem, transactionTypeInfo?: TransactionTypeInfo) => Promise + onCancel: (request: DappRequestStoreItem) => Promise +} + +const DappRequestQueueContext = createContext(undefined) + +export function DappRequestQueueProvider({ children }: PropsWithChildren): JSX.Element { + const dispatch = useAppDispatch() + const [currentIndex, setCurrentIndex] = useState(0) + + // Show the top most pending request + const pendingRequests = useAppSelector((state) => state.dappRequests.pending) + + const request = pendingRequests[currentIndex] + const totalRequestCount = pendingRequests.length + + const activeAccount = useActiveAccountWithThrow() + + // values to help with animations + const [forwards, setForwards] = useState(true) + const [increasing, setIncreasing] = useState(true) + const prevTotalRequestCountRef = useRef(totalRequestCount) + + useEffect(() => { + if (totalRequestCount > prevTotalRequestCountRef.current) { + setIncreasing(true) + } + + if (totalRequestCount < prevTotalRequestCountRef.current) { + setIncreasing(false) + } + + prevTotalRequestCountRef.current = totalRequestCount + }, [totalRequestCount]) + + const dappUrl = extractBaseUrl(request?.senderTabInfo.url) || '' + const dappIconUrl = request?.senderTabInfo?.favIconUrl || '' + + let currentAccount = activeAccount + if (request?.dappInfo) { + const { activeConnectedAddress, connectedAccounts } = request.dappInfo + const connectedAccount = connectedAccounts.find((account) => account.address === activeConnectedAddress) + + if (connectedAccount) { + currentAccount = connectedAccount + } + } + + const onConfirm = async ( + requestToConfirm: DappRequestStoreItem, + transactionTypeInfo?: TransactionTypeInfo, + ): Promise => { + const requestWithTxInfo = { + ...requestToConfirm, + transactionTypeInfo, + } + if (isDappRequestWithDappInfo(requestWithTxInfo)) { + await dispatch(confirmRequest(requestWithTxInfo)) + } else { + await dispatch(confirmRequestNoDappInfo(requestWithTxInfo)) + } + + setCurrentIndex((prev) => Math.max(0, prev - 1)) + } + + const onCancel = async (requestToCancel: DappRequestStoreItem): Promise => { + await dispatch( + rejectRequest({ + senderTabInfo: requestToCancel.senderTabInfo, + errorResponse: { + requestId: requestToCancel.dappRequest.requestId, + type: DappResponseType.ErrorResponse, + error: serializeError(providerErrors.userRejectedRequest()), + }, + }), + ) + + setCurrentIndex((prev) => Math.max(0, prev - 1)) + } + + const onPressNext = (): void => { + setForwards(true) + setCurrentIndex((prev) => Math.min(prev + 1, totalRequestCount - 1)) + } + + const onPressPrevious = (): void => { + setForwards(false) + setCurrentIndex((prev) => Math.max(0, prev - 1)) + } + + const value = { + forwards, + increasing, + currentIndex, + totalRequestCount, + request, + dappUrl, + dappIconUrl, + currentAccount, + onConfirm, + onCancel, + onPressNext, + onPressPrevious, + } + + return {children} +} + +export function useDappRequestQueueContext(): DappRequestQueueContextValue { + const context = useContext(DappRequestQueueContext) + + if (context === undefined) { + throw new Error('useDappRequestQueueContext must be used within a DappRequestQueueProvider') + } + + return context +} diff --git a/apps/extension/src/app/features/dappRequests/accounts.ts b/apps/extension/src/app/features/dappRequests/accounts.ts new file mode 100644 index 00000000000..6970e63f864 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/accounts.ts @@ -0,0 +1,154 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { JsonRpcProvider } from '@ethersproject/providers' +import { providerErrors, serializeError } from '@metamask/rpc-errors' +import { saveDappConnection } from 'src/app/features/dapp/actions' +import { DappInfo, dappStore } from 'src/app/features/dapp/store' +import { getOrderedConnectedAddresses } from 'src/app/features/dapp/utils' +import { SenderTabInfo } from 'src/app/features/dappRequests/slice' +import { + AccountResponse, + DappRequest, + DappResponseType, + ErrorResponse, + GetAccountRequest, + RequestAccountRequest, +} from 'src/app/features/dappRequests/types/DappRequestTypes' +import { extractBaseUrl } from 'src/app/features/dappRequests/utils' +import { dappResponseMessageChannel } from 'src/background/messagePassing/messageChannels' +import { call, put } from 'typed-redux-saga' +import { chainIdToHexadecimalString } from 'uniswap/src/features/chains/utils' +import { ExtensionEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { UniverseChainId, WalletChainId } from 'uniswap/src/types/chains' +import { pushNotification } from 'wallet/src/features/notifications/slice' +import { AppNotificationType } from 'wallet/src/features/notifications/types' +import { getProvider } from 'wallet/src/features/wallet/context' +import { selectActiveAccount } from 'wallet/src/features/wallet/selectors' +import { appSelect } from 'wallet/src/state' + +function getAccountResponse( + chainId: WalletChainId, + dappRequest: DappRequest, + provider: JsonRpcProvider, + dappInfo: DappInfo, +): AccountResponse { + const orderedConnectedAddresses = getOrderedConnectedAddresses( + dappInfo.connectedAccounts, + dappInfo.activeConnectedAddress, + ) + + return { + type: DappResponseType.AccountResponse, + requestId: dappRequest.requestId, + connectedAddresses: orderedConnectedAddresses, + chainId: chainIdToHexadecimalString(chainId), + providerUrl: provider.connection.url, + } +} + +function sendAccountResponseAnalyticsEvent( + senderUrl: string, + chainId: WalletChainId, + dappInfo: DappInfo, + accountResponse: AccountResponse, +): void { + const dappUrl = extractBaseUrl(senderUrl) + + sendAnalyticsEvent(ExtensionEventName.DappConnect, { + dappUrl: dappUrl ?? '', + chainId, + activeConnectedAddress: dappInfo.activeConnectedAddress, + connectedAddresses: accountResponse.connectedAddresses, + }) +} +/** + * Gets the active account, and returns the account address, chainId, and providerUrl. + * Chain id + provider url are from the last connected chain for the dApp and wallet. If this has not been set, it will be the default chain and provider. + */ +export function* getAccount( + dappRequest: GetAccountRequest | RequestAccountRequest, + { id, url }: SenderTabInfo, + dappInfo: DappInfo, +) { + const chainId = dappInfo.lastChainId + const provider = yield* call(getProvider, chainId) + + const response = getAccountResponse(chainId, dappRequest, provider, dappInfo) + sendAccountResponseAnalyticsEvent(url, chainId, dappInfo, response) + + yield* call(dappResponseMessageChannel.sendMessageToTab, id, response) +} + +/** + * Saves the active account as connected to the dapp and parses out necessary data + * Triggers a notification for new connections + */ +export function* saveAccount({ url, favIconUrl }: SenderTabInfo) { + const activeAccount = yield* appSelect(selectActiveAccount) + const dappUrl = extractBaseUrl(url) + const dappInfo = yield* call(dappStore.getDappInfo, dappUrl) + + if (!dappUrl || !activeAccount) { + return + } + + yield* call(saveDappConnection, dappUrl, activeAccount) + // No dapp info means that this is a first time connection request + if (!dappInfo) { + yield* put( + pushNotification({ + type: AppNotificationType.DappConnected, + dappIconUrl: favIconUrl, + }), + ) + } + + const chainId = dappInfo?.lastChainId ?? UniverseChainId.Mainnet + const provider = yield* call(getProvider, chainId) + const connectedAddresses = (dappUrl && (yield* call(dappStore.getDappOrderedConnectedAddresses, dappUrl))) || [] + + return { + dappUrl, + activeAccount, + connectedAddresses, + chainId, + providerUrl: provider.connection.url, + } +} + +/** + * Gets the active account, and returns the account address, chainId, and providerUrl. + * Chain id + provider url are from the last connected chain for the dApp and wallet. If this has not been set, it will be the default chain and provider. + */ +export function* getAccountRequest(request: RequestAccountRequest, senderTabInfo: SenderTabInfo) { + const accountInfo = yield* call(saveAccount, senderTabInfo) + + if (!accountInfo) { + const errorReponse: ErrorResponse = { + type: DappResponseType.ErrorResponse, + error: serializeError(providerErrors.unauthorized()), + requestId: request.requestId, + } + + yield* call(dappResponseMessageChannel.sendMessageToTab, senderTabInfo.id, errorReponse) + } else { + const { dappUrl, activeAccount, connectedAddresses, chainId, providerUrl } = accountInfo + + const accountResponse: AccountResponse = { + type: DappResponseType.AccountResponse, + requestId: request.requestId, + connectedAddresses, + chainId: chainIdToHexadecimalString(chainId), + providerUrl, + } + + yield* call(dappResponseMessageChannel.sendMessageToTab, senderTabInfo.id, accountResponse) + + sendAnalyticsEvent(ExtensionEventName.DappConnectRequest, { + dappUrl, + chainId, + activeConnectedAddress: activeAccount.address, + connectedAddresses, + }) + } +} diff --git a/apps/extension/src/app/features/dappRequests/dappRequestApprovalWatcherSaga.ts b/apps/extension/src/app/features/dappRequests/dappRequestApprovalWatcherSaga.ts new file mode 100644 index 00000000000..b2edf654423 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/dappRequestApprovalWatcherSaga.ts @@ -0,0 +1,252 @@ +/* eslint-disable complexity */ +import { providerErrors, serializeError } from '@metamask/rpc-errors' +import { PayloadAction } from '@reduxjs/toolkit' +import { getAccount, getAccountRequest } from 'src/app/features/dappRequests/accounts' +import { getChainId, getChainIdNoDappInfo } from 'src/app/features/dappRequests/getChainId' +import { + handleGetPermissionsRequest, + handleRequestPermissions, + handleRevokePermissions, +} from 'src/app/features/dappRequests/permissions' +import { + DappRequestNoDappInfo, + DappRequestRejectParams, + DappRequestWithDappInfo, + changeChainSaga, + confirmRequest, + confirmRequestNoDappInfo, + handleSendTransaction, + handleSignMessage, + handleSignTypedData, + handleUniswapOpenSidebarRequest, + rejectAllRequests, + rejectRequest, +} from 'src/app/features/dappRequests/saga' +import { dappRequestActions } from 'src/app/features/dappRequests/slice' +import { + BaseSendTransactionRequest, + BaseSendTransactionRequestSchema, + ChangeChainRequest, + ChangeChainRequestSchema, + DappRequestType, + DappResponseType, + ErrorResponse, + GetAccountRequest, + GetAccountRequestSchema, + GetChainIdRequest, + GetChainIdRequestSchema, + GetPermissionsRequest, + GetPermissionsRequestSchema, + RequestAccountRequest, + RequestAccountRequestSchema, + RequestPermissionsRequest, + RequestPermissionsRequestSchema, + RevokePermissionsRequest, + RevokePermissionsRequestSchema, + SignMessageRequest, + SignMessageRequestSchema, + SignTypedDataRequest, + SignTypedDataRequestSchema, + UniswapOpenSidebarRequest, + UniswapOpenSidebarRequestSchema, +} from 'src/app/features/dappRequests/types/DappRequestTypes' +import { dappResponseMessageChannel } from 'src/background/messagePassing/messageChannels' +import { appSelect } from 'src/store/store' +import { WebState } from 'src/store/webReducer' +import { call, put, takeEvery } from 'typed-redux-saga' +import { logger } from 'utilities/src/logger/logger' + +function* dappRequestApproval({ + type, + payload: request, +}: PayloadAction) { + if (type === rejectAllRequests.type) { + const pendingRequests = yield* appSelect((state: WebState) => state.dappRequests.pending) + + for (const pendingRequest of pendingRequests) { + const errorResponse: ErrorResponse = { + type: DappResponseType.ErrorResponse, + error: serializeError(providerErrors.userRejectedRequest()), + requestId: pendingRequest.dappRequest.requestId, + } + + yield* call(dappResponseMessageChannel.sendMessageToTab, pendingRequest.senderTabInfo.id, errorResponse) + } + + yield* put(dappRequestActions.removeAll()) + return + } + + const requestId = + ('dappRequest' in request && request?.dappRequest?.requestId) || + ('errorResponse' in request && request?.errorResponse?.requestId) + const { id: senderTabId } = request.senderTabInfo + + if (!senderTabId) { + throw new Error('senderTabId is required') + } + if (!requestId) { + throw new Error('requestId is required') + } + + try { + if (type === confirmRequest.type) { + const confirmedRequest = request as DappRequestWithDappInfo + logger.info('dappRequestApprovalWatcher', 'confirmRequest', 'confirm request', request) + + switch (confirmedRequest.dappRequest.type) { + case DappRequestType.RequestPermissions: { + const validatedRequest: RequestPermissionsRequest = RequestPermissionsRequestSchema.parse( + confirmedRequest.dappRequest, + ) + yield* call( + handleRequestPermissions, + validatedRequest, + confirmedRequest.senderTabInfo, + confirmedRequest.dappInfo, + ) + break + } + case DappRequestType.RevokePermissions: { + const validatedRequest: RevokePermissionsRequest = RevokePermissionsRequestSchema.parse( + confirmedRequest.dappRequest, + ) + yield* call(handleRevokePermissions, validatedRequest, confirmedRequest.senderTabInfo) + break + } + case DappRequestType.GetPermissions: { + const validatedRequest: GetPermissionsRequest = GetPermissionsRequestSchema.parse( + confirmedRequest.dappRequest, + ) + yield* call( + handleGetPermissionsRequest, + validatedRequest, + confirmedRequest.senderTabInfo, + confirmedRequest.dappInfo, + ) + break + } + case DappRequestType.SendTransaction: { + const validatedRequest: BaseSendTransactionRequest = BaseSendTransactionRequestSchema.parse( + confirmedRequest.dappRequest, + ) + yield* call( + handleSendTransaction, + validatedRequest, + confirmedRequest.senderTabInfo, + confirmedRequest.dappInfo, + confirmedRequest.transactionTypeInfo, + ) + break + } + case DappRequestType.GetAccount: { + const validatedRequest: GetAccountRequest = GetAccountRequestSchema.parse(confirmedRequest.dappRequest) + yield* call(getAccount, validatedRequest, confirmedRequest.senderTabInfo, confirmedRequest.dappInfo) + break + } + case DappRequestType.RequestAccount: { + const validatedRequest: RequestAccountRequest = RequestAccountRequestSchema.parse( + confirmedRequest.dappRequest, + ) + yield* call(getAccountRequest, validatedRequest, confirmedRequest.senderTabInfo, confirmedRequest.dappInfo) + break + } + case DappRequestType.GetChainId: { + const validatedRequest: GetChainIdRequest = GetChainIdRequestSchema.parse(confirmedRequest.dappRequest) + yield* call(getChainId, validatedRequest, confirmedRequest.senderTabInfo, confirmedRequest.dappInfo) + break + } + case DappRequestType.ChangeChain: { + const validatedRequest: ChangeChainRequest = ChangeChainRequestSchema.parse(confirmedRequest.dappRequest) + yield* call(changeChainSaga, validatedRequest, confirmedRequest.senderTabInfo, confirmedRequest.dappInfo) + break + } + case DappRequestType.SignMessage: { + const validatedRequest: SignMessageRequest = SignMessageRequestSchema.parse(confirmedRequest.dappRequest) + yield* call(handleSignMessage, validatedRequest, confirmedRequest.senderTabInfo, confirmedRequest.dappInfo) + break + } + case DappRequestType.SignTypedData: { + const validatedRequest: SignTypedDataRequest = SignTypedDataRequestSchema.parse(confirmedRequest.dappRequest) + yield* call(handleSignTypedData, validatedRequest, confirmedRequest.senderTabInfo, confirmedRequest.dappInfo) + break + } + // Add more request types here + } + } else if (type === confirmRequestNoDappInfo.type) { + const confirmedRequest = request as DappRequestNoDappInfo + switch (confirmedRequest.dappRequest.type) { + case DappRequestType.RequestAccount: { + const validatedRequest = RequestAccountRequestSchema.parse(confirmedRequest.dappRequest) + yield* call(getAccountRequest, validatedRequest, confirmedRequest.senderTabInfo) + break + } + case DappRequestType.RequestPermissions: { + const validatedRequest: RequestPermissionsRequest = RequestPermissionsRequestSchema.parse( + confirmedRequest.dappRequest, + ) + yield* call(handleRequestPermissions, validatedRequest, confirmedRequest.senderTabInfo) + break + } + case DappRequestType.RevokePermissions: { + const validatedRequest: RevokePermissionsRequest = RevokePermissionsRequestSchema.parse( + confirmedRequest.dappRequest, + ) + yield* call(handleRevokePermissions, validatedRequest, confirmedRequest.senderTabInfo) + break + } + case DappRequestType.GetPermissions: { + const validatedRequest: GetPermissionsRequest = GetPermissionsRequestSchema.parse( + confirmedRequest.dappRequest, + ) + yield* call(handleGetPermissionsRequest, validatedRequest, confirmedRequest.senderTabInfo) + break + } + case DappRequestType.GetChainId: { + const validatedRequest: GetChainIdRequest = GetChainIdRequestSchema.parse(confirmedRequest.dappRequest) + yield* call(getChainIdNoDappInfo, validatedRequest, confirmedRequest.senderTabInfo) + break + } + case DappRequestType.UniswapOpenSidebar: { + const validatedRequest: UniswapOpenSidebarRequest = UniswapOpenSidebarRequestSchema.parse( + confirmedRequest.dappRequest, + ) + yield* call(handleUniswapOpenSidebarRequest, validatedRequest, confirmedRequest.senderTabInfo) + break + } + } + } else if (type === rejectRequest.type) { + const rejectedRequest = request as DappRequestRejectParams + logger.info('dappRequestApprovalWatcher', 'rejectRequest', 'dapp request rejected', request) + + const errorResponse: ErrorResponse = { + type: DappResponseType.ErrorResponse, + error: rejectedRequest.errorResponse.error, + requestId: rejectedRequest.errorResponse.requestId, + } + + yield* call(dappResponseMessageChannel.sendMessageToTab, rejectedRequest.senderTabInfo.id, errorResponse) + } + } catch (error) { + logger.error(error, { + tags: { file: 'dappRequestApprovalWatcherSaga', function: 'dappRequestApprovalWatcher' }, + }) + + const errorResponse: ErrorResponse = { + type: DappResponseType.ErrorResponse, + requestId, + error: serializeError(error), + } + + yield* call(dappResponseMessageChannel.sendMessageToTab, senderTabId, errorResponse) + } finally { + yield* put(dappRequestActions.remove(requestId)) + } +} + +/** + * Watch for pending requests to be confirmed or rejected and dispatch action + */ +export function* dappRequestApprovalWatcher() { + yield* takeEvery([confirmRequestNoDappInfo, confirmRequest, rejectRequest, rejectAllRequests], dappRequestApproval) +} diff --git a/apps/extension/src/app/features/dappRequests/getChainId.ts b/apps/extension/src/app/features/dappRequests/getChainId.ts new file mode 100644 index 00000000000..4cbfc52ccee --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/getChainId.ts @@ -0,0 +1,34 @@ +import { DappInfo } from 'src/app/features/dapp/store' +import { SenderTabInfo } from 'src/app/features/dappRequests/slice' +import { + ChainIdResponse, + DappResponseType, + GetChainIdRequest, +} from 'src/app/features/dappRequests/types/DappRequestTypes' +import { dappResponseMessageChannel } from 'src/background/messagePassing/messageChannels' +import { call } from 'typed-redux-saga' +import { chainIdToHexadecimalString } from 'uniswap/src/features/chains/utils' +import { UniverseChainId } from 'uniswap/src/types/chains' + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function* getChainId(request: GetChainIdRequest, { id }: SenderTabInfo, dappInfo: DappInfo) { + const response: ChainIdResponse = { + type: DappResponseType.ChainIdResponse, + requestId: request.requestId, + chainId: chainIdToHexadecimalString(dappInfo.lastChainId), + } + + yield* call(dappResponseMessageChannel.sendMessageToTab, id, response) +} + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function* getChainIdNoDappInfo(request: GetChainIdRequest, { id }: SenderTabInfo) { + // Sending mainnet as default chain for unconnected dapps + const response: ChainIdResponse = { + type: DappResponseType.ChainIdResponse, + requestId: request.requestId, + chainId: chainIdToHexadecimalString(UniverseChainId.Mainnet), + } + + yield* call(dappResponseMessageChannel.sendMessageToTab, id, response) +} diff --git a/apps/extension/src/app/features/dappRequests/permissions.ts b/apps/extension/src/app/features/dappRequests/permissions.ts new file mode 100644 index 00000000000..3ace013c415 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/permissions.ts @@ -0,0 +1,126 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { rpcErrors, serializeError } from '@metamask/rpc-errors' +import { logger } from 'ethers' +import { removeDappConnection } from 'src/app/features/dapp/actions' +import { DappInfo } from 'src/app/features/dapp/store' +import { saveAccount } from 'src/app/features/dappRequests/accounts' +import { SenderTabInfo } from 'src/app/features/dappRequests/slice' +import { + DappResponseType, + ErrorResponse, + GetPermissionsRequest, + GetPermissionsResponse, + RequestPermissionsRequest, + RequestPermissionsResponse, + RevokePermissionsRequest, + RevokePermissionsResponse, +} from 'src/app/features/dappRequests/types/DappRequestTypes' +import { extractBaseUrl } from 'src/app/features/dappRequests/utils' +import { dappResponseMessageChannel } from 'src/background/messagePassing/messageChannels' +import { Permission } from 'src/contentScript/WindowEthereumRequestTypes' +import { ExtensionEthMethods } from 'src/contentScript/methodHandlers/requestMethods' +import { call, put } from 'typed-redux-saga' +import { chainIdToHexadecimalString } from 'uniswap/src/features/chains/utils' +import { pushNotification } from 'wallet/src/features/notifications/slice' +import { AppNotificationType } from 'wallet/src/features/notifications/types' + +export function getPermissions(dappUrl: string | undefined, connectedAddresses: Address[] | undefined): Permission[] { + const permissions: Permission[] = [] + const isDappConnected = connectedAddresses && connectedAddresses.length > 0 + if (isDappConnected && dappUrl) { + // Safe to assume the eth_accounts permission can be added here, + // since dappInfo will only exist if it as been approved already + permissions.push({ + invoker: dappUrl, + parentCapability: ExtensionEthMethods.eth_accounts, + caveats: [], + }) + } + + return permissions +} + +export function* handleGetPermissionsRequest( + request: GetPermissionsRequest, + { id, url }: SenderTabInfo, + dappInfo?: DappInfo, +) { + const permissions: Permission[] = [] + if (dappInfo) { + permissions.push({ + invoker: url, + parentCapability: ExtensionEthMethods.eth_accounts, + caveats: [], + }) + } + + const response: GetPermissionsResponse = { + type: DappResponseType.GetPermissionsResponse, + requestId: request.requestId, + permissions, + } + yield* call(dappResponseMessageChannel.sendMessageToTab, id, response) +} + +export function* handleRequestPermissions(request: RequestPermissionsRequest, senderTabInfo: SenderTabInfo) { + const requestedPermissions = Object.keys(request.permissions) + + if (requestedPermissions.includes(ExtensionEthMethods.eth_accounts)) { + // Pre-emptively save the dapp connection, to avoid double-approval when dapp calls eth_requestAccounts + const accountInfo = yield* call(saveAccount, senderTabInfo) + const accounts = accountInfo && { + connectedAddresses: accountInfo.connectedAddresses, + chainId: chainIdToHexadecimalString(accountInfo.chainId), + providerUrl: accountInfo.providerUrl, + } + + const permissions: Permission[] = [ + { + invoker: senderTabInfo.url, + parentCapability: ExtensionEthMethods.eth_accounts, + caveats: [], + }, + ] + const response: RequestPermissionsResponse = { + type: DappResponseType.RequestPermissionsResponse, + requestId: request.requestId, + permissions, + accounts, + } + yield* call(dappResponseMessageChannel.sendMessageToTab, senderTabInfo.id, response) + } else { + logger.info('saga.ts', 'handleRequestPermissions', 'Unknown permissions requested', requestedPermissions) + yield* call(dappResponseMessageChannel.sendMessageToTab, senderTabInfo.id, { + type: DappResponseType.ErrorResponse, + error: serializeError(rpcErrors.methodNotFound()), + requestId: request.requestId, + } satisfies ErrorResponse) + } +} + +export function* handleRevokePermissions(request: RevokePermissionsRequest, senderTabInfo: SenderTabInfo) { + const revokedPermissions = Object.keys(request.permissions) + + if (revokedPermissions.includes(ExtensionEthMethods.eth_accounts)) { + const dappUrl = extractBaseUrl(senderTabInfo.url) + + if (!dappUrl) { + return + } + + yield* call(removeDappConnection, dappUrl, undefined) + yield* put(pushNotification({ type: AppNotificationType.DappDisconnected, dappIconUrl: senderTabInfo.favIconUrl })) + + yield* call(dappResponseMessageChannel.sendMessageToTab, senderTabInfo.id, { + type: DappResponseType.RevokePermissionsResponse, + requestId: request.requestId, + } satisfies RevokePermissionsResponse) + } else { + logger.info('saga.ts', 'handleRevokePermissions', 'Unknown permissions requested', revokedPermissions) + yield* call(dappResponseMessageChannel.sendMessageToTab, senderTabInfo.id, { + type: DappResponseType.ErrorResponse, + error: serializeError(rpcErrors.methodNotFound()), + requestId: request.requestId, + } satisfies ErrorResponse) + } +} diff --git a/apps/extension/src/app/features/dappRequests/requestContent/Connection/ConnectionRequestContent.tsx b/apps/extension/src/app/features/dappRequests/requestContent/Connection/ConnectionRequestContent.tsx new file mode 100644 index 00000000000..be130ca36d9 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/requestContent/Connection/ConnectionRequestContent.tsx @@ -0,0 +1,27 @@ +import { useTranslation } from 'react-i18next' +import { DappRequestContent } from 'src/app/features/dappRequests/DappRequestContent' +import { Flex, Text } from 'ui/src' + +export function ConnectionRequestContent(): JSX.Element { + const { t } = useTranslation() + + return ( + + + + {t('dapp.request.connect.helptext')} + + + + ) +} diff --git a/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Approve/ApproveRequestContent.tsx b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Approve/ApproveRequestContent.tsx new file mode 100644 index 00000000000..a236214b83e --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Approve/ApproveRequestContent.tsx @@ -0,0 +1,104 @@ +import { useTranslation } from 'react-i18next' +import { useDappLastChainId } from 'src/app/features/dapp/hooks' +import { DappRequestContent } from 'src/app/features/dappRequests/DappRequestContent' +import { useDappRequestQueueContext } from 'src/app/features/dappRequests/DappRequestQueueContext' +import { + ApproveSendTransactionRequest, + DappRequest as DappRequestBaseType, + DappRequestType, +} from 'src/app/features/dappRequests/types/DappRequestTypes' +import { Flex, Text } from 'ui/src' +import { iconSizes } from 'ui/src/theme' +import { CurrencyLogo } from 'uniswap/src/components/CurrencyLogo/CurrencyLogo' +import { uniswapUrls } from 'uniswap/src/constants/urls' +import { CurrencyInfo } from 'uniswap/src/features/dataApi/types' +import { buildCurrencyId } from 'uniswap/src/utils/currencyId' +import { LearnMoreLink } from 'wallet/src/components/text/LearnMoreLink' +import { GasFeeResult } from 'wallet/src/features/gas/types' +import { useCurrencyInfo } from 'wallet/src/features/tokens/useCurrencyInfo' +import { TransactionType, TransactionTypeInfo } from 'wallet/src/features/transactions/types' + +function useDappRequestTokenRecipientInfo(request: DappRequestBaseType, dappUrl: string): Maybe { + const activeChain = useDappLastChainId(dappUrl) + const type = request.type + const to = type === DappRequestType.SendTransaction ? request.transaction.to : undefined + + const identifier = + activeChain && type === DappRequestType.SendTransaction && to ? buildCurrencyId(activeChain, to) : undefined + + return useCurrencyInfo(identifier) +} + +function parseSpenderAddress(data: string): string { + // Check if the data is of the correct length for "approve(address,uint256)" + // It should have 10 characters for "0x" + function selector and 64 characters for each parameter + if (data.length !== 10 + 64 * 2) { + throw new Error('Invalid data length') + } + + // The first argument (address) starts 10 characters in (after "0x" + 8 characters for function selector) + // and spans the next 64 characters, but the first 24 are padding zeros for the 40-character address + const addressHex = data.slice(34, 74) // From position 34 to 74 to capture the address + + // Validate if the address hex is correctly formatted + if (!/^[0-9a-fA-F]{40}$/.test(addressHex)) { + throw new Error('Invalid characters in hex string') + } + + return `0x${addressHex}` +} + +interface ApproveRequestContentProps { + transactionGasFeeResult: GasFeeResult + dappRequest: ApproveSendTransactionRequest + onCancel: () => Promise + onConfirm: (transactionTypeInfo?: TransactionTypeInfo) => Promise +} + +export function ApproveRequestContent({ + dappRequest, + transactionGasFeeResult, + onCancel, + onConfirm, +}: ApproveRequestContentProps): JSX.Element { + const { t } = useTranslation() + const { dappUrl } = useDappRequestQueueContext() + + const tokenInfo = useDappRequestTokenRecipientInfo(dappRequest, dappUrl) + const tokenSymbol = tokenInfo?.currency.symbol + const spender = parseSpenderAddress(dappRequest.transaction.data) + const transactionTypeInfo: TransactionTypeInfo | undefined = dappRequest.transaction.to + ? { + type: TransactionType.Approve, + tokenAddress: dappRequest.transaction.to, + spender, + } + : undefined + const onConfirmWithTransactionTypeInfo = (): Promise => onConfirm(transactionTypeInfo) + + return ( + } + title={tokenSymbol ? t('dapp.request.approve.title', { tokenSymbol }) : t('dapp.request.approve.fallbackTitle')} + transactionGasFeeResult={transactionGasFeeResult} + onCancel={onCancel} + onConfirm={onConfirmWithTransactionTypeInfo} + > + + + {t('dapp.request.approve.helptext')} + + + + + ) +} diff --git a/apps/extension/src/app/features/dappRequests/requestContent/EthSend/EthSend.tsx b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/EthSend.tsx new file mode 100644 index 00000000000..33d54ea3c6b --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/EthSend.tsx @@ -0,0 +1,121 @@ +import { useCallback, useEffect, useMemo } from 'react' +import { useDappLastChainId } from 'src/app/features/dapp/hooks' +import { useDappRequestQueueContext } from 'src/app/features/dappRequests/DappRequestQueueContext' +import { ApproveRequestContent } from 'src/app/features/dappRequests/requestContent/EthSend/Approve/ApproveRequestContent' +import { FallbackEthSendRequestContent } from 'src/app/features/dappRequests/requestContent/EthSend/FallbackEthSend/FallbackEthSend' +import { LPRequestContent } from 'src/app/features/dappRequests/requestContent/EthSend/LP/LPRequestContent' +import { SwapRequestContent } from 'src/app/features/dappRequests/requestContent/EthSend/Swap/SwapRequestContent' +import { + DappRequestStoreItemForEthSendTxn, + isApproveRequest, + isLPRequest, + isSwapRequest, +} from 'src/app/features/dappRequests/types/DappRequestTypes' +import { PollingInterval } from 'uniswap/src/constants/misc' +import { logger } from 'utilities/src/logger/logger' +import { formatExternalTxnWithGasEstimates } from 'wallet/src/features/gas/formatExternalTxnWithGasEstimates' +import { useTransactionGasFee } from 'wallet/src/features/gas/hooks' +import { GasFeeResult, GasSpeed } from 'wallet/src/features/gas/types' +import { TransactionTypeInfo } from 'wallet/src/features/transactions/types' + +interface EthSendRequestContentProps { + request: DappRequestStoreItemForEthSendTxn +} + +export function EthSendRequestContent({ request }: EthSendRequestContentProps): JSX.Element { + const { dappRequest } = request + const { dappUrl, onConfirm, onCancel } = useDappRequestQueueContext() + const chainId = useDappLastChainId(dappUrl) + + // Gas service requires a chain id + const formattedTxnForGasQuery = { ...dappRequest.transaction, chainId } + + const transactionGasFeeResult = useTransactionGasFee( + formattedTxnForGasQuery, + /*speed=*/ GasSpeed.Urgent, + /*skip=*/ !formattedTxnForGasQuery, + /*pollingInterval=*/ PollingInterval.LightningMcQueen, + ) + + const isInvalidGasFeeResult = isInvalidGasFeeResultForEthSend(transactionGasFeeResult) + + useEffect(() => { + if (isInvalidGasFeeResult) { + logger.error( + new Error(transactionGasFeeResult.error?.toString() ?? 'Empty gas fee result for dapp txn request.'), + { + tags: { file: 'features/dappRequests/DappRequestContent, ', function: 'DappRequest' }, + extra: { dappRequest }, + }, + ) + } + }, [dappRequest, isInvalidGasFeeResult, transactionGasFeeResult]) + + const requestWithGasValues = useMemo(() => { + const txnWithFormattedGasEstimates = formatExternalTxnWithGasEstimates({ + transaction: dappRequest.transaction, + gasFeeResult: transactionGasFeeResult, + }) + + return { + ...request, + dappRequest: { + ...request.dappRequest, + transaction: txnWithFormattedGasEstimates, + }, + } + }, [dappRequest.transaction, request, transactionGasFeeResult]) + + const onConfirmRequest = useCallback( + async (transactionTypeInfo?: TransactionTypeInfo) => { + await onConfirm(requestWithGasValues, transactionTypeInfo) + }, + [onConfirm, requestWithGasValues], + ) + + const onCancelRequest = useCallback(async () => { + await onCancel(requestWithGasValues) + }, [onCancel, requestWithGasValues]) + + if (isSwapRequest(dappRequest)) { + return ( + + ) + } else if (isLPRequest(dappRequest)) { + return ( + + ) + } else if (isApproveRequest(dappRequest)) { + return ( + + ) + } else { + return ( + + ) + } +} + +function isInvalidGasFeeResultForEthSend(gasFeeResult: GasFeeResult): boolean { + return !!gasFeeResult.error || (!gasFeeResult.loading && (!gasFeeResult.params || !gasFeeResult.value)) +} diff --git a/apps/extension/src/app/features/dappRequests/requestContent/EthSend/FallbackEthSend/FallbackEthSend.tsx b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/FallbackEthSend/FallbackEthSend.tsx new file mode 100644 index 00000000000..ac106dc3797 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/FallbackEthSend/FallbackEthSend.tsx @@ -0,0 +1,118 @@ +import { BigNumber } from 'ethers' +import { useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { useDappLastChainId } from 'src/app/features/dapp/hooks' +import { DappRequestContent } from 'src/app/features/dappRequests/DappRequestContent' +import { useDappRequestQueueContext } from 'src/app/features/dappRequests/DappRequestQueueContext' +import { SendTransactionRequest } from 'src/app/features/dappRequests/types/DappRequestTypes' +import { useCopyToClipboard } from 'src/app/hooks/useOnCopyToClipboard' +import { Anchor, Flex, Text, TouchableArea } from 'ui/src' +import { AnimatedCopySheets, ExternalLink } from 'ui/src/components/icons' +import { ellipseMiddle, shortenAddress } from 'utilities/src/addresses' +import { GasFeeResult } from 'wallet/src/features/gas/types' +import { CopyNotificationType } from 'wallet/src/features/notifications/types' +import { ContentRow } from 'wallet/src/features/transactions/TransactionRequest/ContentRow' +import { SpendingDetails } from 'wallet/src/features/transactions/TransactionRequest/SpendingDetails' +import { ExplorerDataType, getExplorerLink } from 'wallet/src/utils/linking' + +interface FallbackEthSendRequestProps { + transactionGasFeeResult: GasFeeResult + dappRequest: SendTransactionRequest + onCancel: () => Promise + onConfirm: () => Promise +} + +export function FallbackEthSendRequestContent({ + dappRequest, + transactionGasFeeResult, + onCancel, + onConfirm, +}: FallbackEthSendRequestProps): JSX.Element | null { + const { t } = useTranslation() + const { dappUrl } = useDappRequestQueueContext() + const activeChain = useDappLastChainId(dappUrl) + + const { value: sending, to: toAddress, chainId: transactionChainId } = dappRequest.transaction + const chainId = transactionChainId || activeChain + const recipientLink = chainId && toAddress ? getExplorerLink(chainId, toAddress, ExplorerDataType.ADDRESS) : '' + const contractFunction = dappRequest.transaction.type + const calldata = dappRequest.transaction.data + + const copyToClipboard = useCopyToClipboard() + + const copyCalldata = useCallback( + () => + copyToClipboard({ + textToCopy: calldata, + copyType: CopyNotificationType.Calldata, + }), + [calldata, copyToClipboard], + ) + + return ( + + + {sending && !BigNumber.from(sending).eq(0) && chainId && } + {toAddress && ( + + + + + {shortenAddress(toAddress)} + + + + + + )} + + + {contractFunction || t('common.text.unknown')} + + + {calldata && ( + + + + {ellipseMiddle(calldata)} + + + + + )} + + + ) +} diff --git a/apps/extension/src/app/features/dappRequests/requestContent/EthSend/LP/LPRequestContent.tsx b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/LP/LPRequestContent.tsx new file mode 100644 index 00000000000..ea1615cc2c5 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/LP/LPRequestContent.tsx @@ -0,0 +1,49 @@ +import { useTranslation } from 'react-i18next' +import { DappRequestContent } from 'src/app/features/dappRequests/DappRequestContent' +import { LPSendTransactionRequest } from 'src/app/features/dappRequests/types/DappRequestTypes' +import { Flex, Text } from 'ui/src' +import { GasFeeResult } from 'wallet/src/features/gas/types' + +interface LPRequestContentProps { + transactionGasFeeResult: GasFeeResult + dappRequest: LPSendTransactionRequest + onCancel: () => Promise + onConfirm: () => Promise +} + +export function LPRequestContent({ + dappRequest, + transactionGasFeeResult, + onCancel, + onConfirm, +}: LPRequestContentProps): JSX.Element { + const { t } = useTranslation() + + return ( + + + {dappRequest.parsedCalldata.commands.map((command) => ( + + {command.commandName} + + ))} + + + ) +} diff --git a/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Swap/SwapRequestContent.tsx b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Swap/SwapRequestContent.tsx new file mode 100644 index 00000000000..28bd0f53c19 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Swap/SwapRequestContent.tsx @@ -0,0 +1,291 @@ +/* eslint-disable complexity */ +import { useTranslation } from 'react-i18next' +import { useDappLastChainId } from 'src/app/features/dapp/hooks' +import { DappRequestContent } from 'src/app/features/dappRequests/DappRequestContent' +import { useDappRequestQueueContext } from 'src/app/features/dappRequests/DappRequestQueueContext' +import { formatUnits } from 'src/app/features/dappRequests/requestContent/EthSend/Swap/utils' +import { SwapSendTransactionRequest } from 'src/app/features/dappRequests/types/DappRequestTypes' +import { + AmountInMaxParam, + AmountInParam, + AmountOutMinParam, + AmountOutParam, + Param, + UniversalRouterCommand, + isAmountInMaxParam, + isAmountInParam, + isAmountOutMinParam, + isAmountOutParam, + isURCommandASwap, +} from 'src/app/features/dappRequests/types/UniversalRouterTypes' +import { Flex, Separator, Text } from 'ui/src' +import { ArrowDown } from 'ui/src/components/icons' +import { iconSizes } from 'ui/src/theme' +import { CurrencyLogo } from 'uniswap/src/components/CurrencyLogo/CurrencyLogo' +import { CurrencyInfo } from 'uniswap/src/features/dataApi/types' +import { UniverseChainId, WalletChainId } from 'uniswap/src/types/chains' +import { buildCurrencyId } from 'uniswap/src/utils/currencyId' +import { assert } from 'utilities/src/errors' +import { NumberType } from 'utilities/src/format/types' +import { SplitLogo } from 'wallet/src/components/CurrencyLogo/SplitLogo' +import { GasFeeResult } from 'wallet/src/features/gas/types' +import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' +import { NativeCurrency } from 'wallet/src/features/tokens/NativeCurrency' +import { useCurrencyInfo } from 'wallet/src/features/tokens/useCurrencyInfo' +import { useUSDCValue } from 'wallet/src/features/transactions/swap/trade/hooks/useUSDCPrice' +import { TransactionType, TransactionTypeInfo } from 'wallet/src/features/transactions/types' +import { ValueType, getCurrencyAmount } from 'wallet/src/utils/getCurrencyAmount' + +function extractPathValues(commands: UniversalRouterCommand[]): { + inputAddress: string | undefined + outputAddress: string | undefined +} { + let inputAddress: string | undefined + let outputAddress: string | undefined + for (const command of commands) { + const param: Param | undefined = command.params.find(({ name }) => name === 'path') + if (!param) { + continue + } + // matches V2SwapExact[In|Out] + if (command.commandName.startsWith('V2SwapExact')) { + const path = param.value as string[] + const first = path[0] + if (first && !inputAddress) { + inputAddress = first + } + const last = path[path.length - 1] + if (last) { + outputAddress = last + } + } + // matches V3SwapExact[In|Out] + if (command.commandName.startsWith('V3SwapExact')) { + const path = param.value as { fee: number; tokenIn: string; tokenOut: string }[] + const first = path[0] + if (first && !inputAddress) { + inputAddress = first.tokenIn + } + const last = path[path.length - 1] + if (last) { + outputAddress = last.tokenOut + } + } + } + return { inputAddress, outputAddress } +} + +function useSwapCurrencyIdentifiers( + request: SwapSendTransactionRequest, + dappUrl: string, +): { inputIdentifier: string | undefined; outputIdentifier: string | undefined } { + const activeChain = useDappLastChainId(dappUrl) + return getSwapCurrencyIdentifiers(request, activeChain) +} + +export function getSwapCurrencyIdentifiers( + request: SwapSendTransactionRequest, + activeChain: WalletChainId | undefined, +): { inputIdentifier: string | undefined; outputIdentifier: string | undefined } { + const { inputAddress, outputAddress } = extractPathValues(request.parsedCalldata.commands) + + const inputIdentifier = activeChain && inputAddress ? buildCurrencyId(activeChain, inputAddress) : undefined + const outputIdentifier = activeChain && outputAddress ? buildCurrencyId(activeChain, outputAddress) : undefined + + return { inputIdentifier, outputIdentifier } +} + +function getTransactionTypeInfo({ + inputCurrencyInfo, + outputCurrencyInfo, + inputAmountRaw, + outputAmountRaw, +}: { + inputCurrencyInfo: Maybe + outputCurrencyInfo: Maybe + inputAmountRaw: string + outputAmountRaw: string +}): TransactionTypeInfo | undefined { + return inputCurrencyInfo?.currencyId && outputCurrencyInfo?.currencyId + ? { + type: TransactionType.Swap, + tradeType: 0, // TradeType.EXACT_INPUT, but TradeType doesn't matter for the UI + inputCurrencyId: inputCurrencyInfo?.currencyId, + outputCurrencyId: outputCurrencyInfo?.currencyId, + inputCurrencyAmountRaw: inputAmountRaw, + expectedOutputCurrencyAmountRaw: outputAmountRaw, + minimumOutputCurrencyAmountRaw: outputAmountRaw, + } + : undefined +} + +interface SwapRequestContentProps { + transactionGasFeeResult: GasFeeResult + dappRequest: SwapSendTransactionRequest + onCancel: () => Promise + onConfirm: (transactionTypeInfo?: TransactionTypeInfo) => Promise +} + +export function SwapRequestContent({ + transactionGasFeeResult, + dappRequest, + onCancel, + onConfirm, +}: SwapRequestContentProps): JSX.Element { + const { t } = useTranslation() + const { dappUrl } = useDappRequestQueueContext() + const { formatCurrencyAmount } = useLocalizationContext() + const activeChain = useDappLastChainId(dappUrl) + + const { inputIdentifier, outputIdentifier } = useSwapCurrencyIdentifiers(dappRequest, dappUrl) + + const inputCurrencyInfo = useCurrencyInfo(inputIdentifier) + const outputCurrencyInfo = useCurrencyInfo(outputIdentifier) + + const isFirstCommandWrappingEth = dappRequest.parsedCalldata.commands[0]?.commandName === 'WrapEth' + const isLastCommandUnwrappingEth = + dappRequest.parsedCalldata.commands[dappRequest.parsedCalldata.commands.length - 1]?.commandName === 'UnwrapWeth' + + const nativeCurrency = NativeCurrency.onChain(activeChain || UniverseChainId.Mainnet) + + const nativeInput = isFirstCommandWrappingEth && inputCurrencyInfo?.currency.equals(nativeCurrency.wrapped) + const nativeOutput = isLastCommandUnwrappingEth && outputCurrencyInfo?.currency.equals(nativeCurrency.wrapped) + const currency0 = nativeInput ? nativeCurrency : inputCurrencyInfo?.currency + const currency1 = nativeOutput ? nativeCurrency : outputCurrencyInfo?.currency + + const firstSwapCommand = dappRequest.parsedCalldata.commands.find(isURCommandASwap) + const lastSwapCommand = dappRequest.parsedCalldata.commands.findLast(isURCommandASwap) + + assert( + firstSwapCommand && lastSwapCommand, + 'SwapRequestContent: All swaps must have a defined input and output Universal Router command.', + ) + + function isAmountInOrMaxParam(param: Param): param is AmountInParam | AmountInMaxParam { + return isAmountInParam(param) || isAmountInMaxParam(param) + } + + function isAmountOutMinOrOutParam(param: Param): param is AmountOutMinParam | AmountOutParam { + return isAmountOutMinParam(param) || isAmountOutParam(param) + } + + // Ideally we would render some UI that makes it clear when you can expect minAmountOut instead of rendering what might look like a bad deal + const firstAmountInParam = firstSwapCommand?.params.find(isAmountInOrMaxParam) + const lastAmountOutParam = lastSwapCommand?.params.find(isAmountOutMinOrOutParam) + + assert( + firstAmountInParam && lastAmountOutParam, + 'SwapRequestContent: All swaps must have a defined input and output amount parameter.', + ) + + const inputAmount = formatUnits( + firstAmountInParam?.value || '0', // should always be defined--`assert` above catches this case + inputCurrencyInfo?.currency.decimals || 18, + ) + const outputAmount = formatUnits( + lastAmountOutParam?.value || '0', // should always be defined--`assert` above catches this case + outputCurrencyInfo?.currency.decimals || 18, + ) + + const inputCurrencyAmount = getCurrencyAmount({ + value: inputAmount, + valueType: ValueType.Exact, + currency: inputCurrencyInfo?.currency, + }) + const inputValue = useUSDCValue(inputCurrencyAmount) + + const outputCurrencyAmount = getCurrencyAmount({ + value: outputAmount, + valueType: ValueType.Exact, + currency: outputCurrencyInfo?.currency, + }) + const outputValue = useUSDCValue(outputCurrencyAmount) + + const showSwapDetails = Boolean(currency0?.symbol && currency1?.symbol) + const showSplitLogo = Boolean(inputCurrencyInfo?.logoUrl && outputCurrencyInfo?.logoUrl) + + // TODO (EXT-1083): add USDC values to SwapTransactionTypeInfo and display on notification toast + // Need the raw uint256 amounts, not the exact floating point amounts + const inputAmountRaw = formatUnits( + firstAmountInParam?.value || '0', // should always be defined--`assert` above catches this case + 0, + ) + const outputAmountRaw = formatUnits( + lastAmountOutParam?.value || '0', // should always be defined--`assert` above catches this case + 0, + ) + const transactionTypeInfo = getTransactionTypeInfo({ + inputCurrencyInfo, + outputCurrencyInfo, + inputAmountRaw, + outputAmountRaw, + }) + const onConfirmWithTransactionTypeInfo = (): Promise => onConfirm(transactionTypeInfo) + + return ( + + ) : undefined + } + title={ + currency0?.symbol && currency1?.symbol + ? t('swap.request.title.full', { + inputCurrencySymbol: currency0?.symbol, + outputCurrencySymbol: currency1?.symbol, + }) + : t('swap.request.title.short') + } + transactionGasFeeResult={transactionGasFeeResult} + onCancel={onCancel} + onConfirm={onConfirmWithTransactionTypeInfo} + > + {showSwapDetails && ( + <> + + + + + + {formatCurrencyAmount({ value: inputCurrencyAmount, type: NumberType.TokenTx })} {currency0?.symbol} + + + {formatCurrencyAmount({ value: inputValue, type: NumberType.FiatTokenPrice })} + + + + + + + + + {formatCurrencyAmount({ value: outputCurrencyAmount, type: NumberType.TokenTx })} {currency1?.symbol} + + + {formatCurrencyAmount({ value: outputValue, type: NumberType.FiatTokenPrice })} + + + + + + + )} + + ) +} diff --git a/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Swap/constants.ts b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Swap/constants.ts new file mode 100644 index 00000000000..9b5861bb32e --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Swap/constants.ts @@ -0,0 +1,13 @@ +import { BigNumber } from 'ethers' + +export const CONTRACT_BALANCE = BigNumber.from(2).pow(255) +export const ETH_ADDRESS = '0x0000000000000000000000000000000000000000' +export const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' +export const MAX_UINT256 = BigNumber.from(2).pow(256).sub(1) +export const MAX_UINT160 = BigNumber.from(2).pow(160).sub(1) + +export const SENDER_AS_RECIPIENT = '0x0000000000000000000000000000000000000001' +export const ROUTER_AS_RECIPIENT = '0x0000000000000000000000000000000000000002' + +export const OPENSEA_CONDUIT_SPENDER_ID = 0 +export const SUDOSWAP_SPENDER_ID = 1 diff --git a/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Swap/universalRouter.ts b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Swap/universalRouter.ts new file mode 100644 index 00000000000..d1a19f49f3b --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Swap/universalRouter.ts @@ -0,0 +1,110 @@ +import { SwapRouter } from '@uniswap/universal-router-sdk' +import { ethers } from 'ethers' +import { + ABI_DEFINITION, + CommandName, + CommandType, + Subparser, + UniversalRouterCall, + UniversalRouterCommand, +} from 'src/app/features/dappRequests/types/UniversalRouterTypes' + +export function parseCalldata(calldata: string): UniversalRouterCall { + const iface = SwapRouter.INTERFACE + const txDescription = iface.parseTransaction({ data: calldata }) + const { commands, inputs } = txDescription.args + // map hex string to bytes + const commandTypes: CommandType[] = [] + + // Start iterating from the third character to skip the "0x" prefix + for (let i = 2; i < commands.length; i += 2) { + // Get two characters from the hexString + const byte = commands.substr(i, 2) + + // Convert it to a number and add it to the values array + commandTypes.push(parseInt(byte, 16) as CommandType) + } + + const parsedCommands = commandTypes.map((commandType: CommandType, i: number): UniversalRouterCommand => { + const abiDef = ABI_DEFINITION[commandType] + const rawParams = ethers.utils.defaultAbiCoder.decode( + abiDef.map((command) => command.type), + inputs[i], + ) + const params = rawParams.map((param, j: number) => { + const fragment = abiDef[j] + if (fragment && fragment.subparser === Subparser.V3PathExactIn) { + return { + name: fragment.name, + value: parseV3PathExactIn(param), + } + } else if (fragment && fragment.subparser === Subparser.V3PathExactOut) { + return { + name: fragment.name, + value: parseV3PathExactOut(param), + } + } else { + return { + name: fragment?.name || '', + value: param, + } + } + }) + return { + commandName: CommandType[commandType] as CommandName, + commandType, + params, + } + }) + return { commands: parsedCommands } +} + +export type V3PathItem = { + readonly tokenIn: string + readonly tokenOut: string + readonly fee: number +} + +export function parseV3PathExactIn(path: string): readonly V3PathItem[] { + const strippedPath = path.replace('0x', '') + let tokenIn = ethers.utils.getAddress(strippedPath.substr(0, 40)) + let loc = 40 + const res = [] + while (loc < strippedPath.length) { + const feeAndTokenOut = strippedPath.substr(loc, 46) + const fee = parseInt(feeAndTokenOut.substr(0, 6), 16) + const tokenOut = ethers.utils.getAddress(feeAndTokenOut.substr(6, 40)) + + res.push({ + tokenIn, + tokenOut, + fee, + }) + tokenIn = tokenOut + loc += 46 + } + + return res +} + +export function parseV3PathExactOut(path: string): readonly V3PathItem[] { + const strippedPath = path.replace('0x', '') + let tokenIn = ethers.utils.getAddress(strippedPath.substr(strippedPath.length - 40, 40)) + let loc = strippedPath.length - 86 // 86 = (20 addr + 3 fee + 20 addr) * 2 (for hex characters) + const res = [] + while (loc >= 0) { + const feeAndTokenOut = strippedPath.substr(loc, 46) + const tokenOut = ethers.utils.getAddress(feeAndTokenOut.substr(0, 40)) + const fee = parseInt(feeAndTokenOut.substr(40, 6), 16) + + res.push({ + tokenIn, + tokenOut, + fee, + }) + tokenIn = tokenOut + loc -= 46 + } + + return res +} diff --git a/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Swap/utils.ts b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Swap/utils.ts new file mode 100644 index 00000000000..639f1aba029 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/requestContent/EthSend/Swap/utils.ts @@ -0,0 +1,70 @@ +import { BigNumber, BigNumberish } from 'ethers' +import { formatUnits as formatUnitsEthers } from 'ethers/lib/utils' +import { + CONTRACT_BALANCE, + MAX_UINT160, + MAX_UINT256, +} from 'src/app/features/dappRequests/requestContent/EthSend/Swap/constants' +import { CommandType, UniversalRouterCall } from 'src/app/features/dappRequests/types/UniversalRouterTypes' + +export type MinimalToken = { + address: string + symbol: string + decimals: number +} +export type TokenDetails = { [address: string]: MinimalToken } + +export type V3TokenInPath = { + tokenIn: string + tokenOut: string + fee: number +} + +export function findErc20TokensToPrepare(urCall: UniversalRouterCall): string[] { + const tokenAddresses: string[] = [] + urCall.commands.forEach((command) => { + switch (command.commandType) { + case CommandType.V2SwapExactIn: + case CommandType.V2SwapExactOut: { + const tokensInPath: string[] | undefined = command.params.find((param) => param.name === 'path')?.value + tokensInPath?.forEach((tokenAddr: string) => tokenAddresses.push(tokenAddr)) + break + } + case CommandType.V3SwapExactIn: + case CommandType.V3SwapExactOut: { + const pools: V3TokenInPath[] | undefined = command.params.find((param) => param.name === 'path')?.value + pools?.forEach(({ tokenIn, tokenOut }) => { + tokenAddresses.push(tokenIn) + tokenAddresses.push(tokenOut) + }) + break + } + case CommandType.PayPortion: + case CommandType.SWEEP: + case CommandType.TRANSFER: { + const tokenAddr = command.params.find((param) => param.name === 'token')?.value + if (tokenAddr) { + tokenAddresses.push(tokenAddr) + } + break + } + } + }) + + return Array.from(new Set(tokenAddresses)) +} + +// Like ethers.formatUnits except it parses specific constants +export function formatUnits(amount: BigNumberish, units: number): string { + if (BigNumber.from(CONTRACT_BALANCE).eq(amount)) { + return 'CONTRACT_BALANCE' + } + if (BigNumber.from(MAX_UINT256).eq(amount)) { + return 'MAX_UINT256' + } + if (BigNumber.from(MAX_UINT160).eq(amount)) { + return 'MAX_UINT160' + } + + return formatUnitsEthers(amount, units) +} diff --git a/apps/extension/src/app/features/dappRequests/requestContent/NetworkFooter.test.tsx b/apps/extension/src/app/features/dappRequests/requestContent/NetworkFooter.test.tsx new file mode 100644 index 00000000000..582a57f48e7 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/requestContent/NetworkFooter.test.tsx @@ -0,0 +1,11 @@ +import { NetworksFooter } from 'src/app/features/dappRequests/requestContent/NetworksFooter' +import { cleanup, render } from 'src/test/test-utils' + +describe(NetworksFooter, () => { + it('renders without error', async () => { + const tree = render() + + expect(tree).toMatchSnapshot() + cleanup() + }) +}) diff --git a/apps/extension/src/app/features/dappRequests/requestContent/NetworksFooter.tsx b/apps/extension/src/app/features/dappRequests/requestContent/NetworksFooter.tsx new file mode 100644 index 00000000000..70130bc007f --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/requestContent/NetworksFooter.tsx @@ -0,0 +1,20 @@ +import { useTranslation } from 'react-i18next' +import { Flex, Text } from 'ui/src' +import { iconSizes } from 'ui/src/theme' +import { NetworksInSeries } from 'uniswap/src/components/network/NetworkFilter' +import { WALLET_SUPPORTED_CHAIN_IDS } from 'uniswap/src/types/chains' + +export function NetworksFooter(): JSX.Element { + const { t } = useTranslation() + + return ( + + + + {t('extension.connection.networks')} + + + + + ) +} diff --git a/apps/extension/src/app/features/dappRequests/requestContent/PersonalSign/PersonalSignRequestContent.tsx b/apps/extension/src/app/features/dappRequests/requestContent/PersonalSign/PersonalSignRequestContent.tsx new file mode 100644 index 00000000000..5a59a2f7250 --- /dev/null +++ b/apps/extension/src/app/features/dappRequests/requestContent/PersonalSign/PersonalSignRequestContent.tsx @@ -0,0 +1,124 @@ +import { ethers } from 'ethers' +import { useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { DappRequestContent } from 'src/app/features/dappRequests/DappRequestContent' +import { SignMessageRequest } from 'src/app/features/dappRequests/types/DappRequestTypes' +import { Button, Flex, Text, Tooltip } from 'ui/src' +import { AlertTriangle, Code, StickyNoteTextSquare } from 'ui/src/components/icons' +import { containsNonPrintableChars } from 'utilities/src/primitives/string' + +enum ViewEncoding { + UTF8, + HEX, +} +interface PersonalSignRequestProps { + dappRequest: SignMessageRequest +} + +export function PersonalSignRequestContent({ dappRequest }: PersonalSignRequestProps): JSX.Element | null { + const { t } = useTranslation() + + const [viewEncoding, setViewEncoding] = useState(ViewEncoding.UTF8) + const toggleViewEncoding = (): void => + setViewEncoding(viewEncoding === ViewEncoding.UTF8 ? ViewEncoding.HEX : ViewEncoding.UTF8) + + const hexMessage = dappRequest.messageHex + const utf8Message = ethers.utils.toUtf8String(hexMessage) // Already validated in schema + + const containsUnrenderableCharacters = containsNonPrintableChars(utf8Message) + + const [isScrollable, setIsScrollable] = useState(false) + const messageRef = useRef(null) + useEffect(() => { + const checkScroll = (): void => { + if (!messageRef.current) { + return + } + setIsScrollable(messageRef.current.scrollHeight > messageRef.current.clientHeight) + } + + checkScroll() + window.addEventListener('resize', checkScroll) + + return () => window.removeEventListener('resize', checkScroll) + }, [setIsScrollable, viewEncoding]) + + return ( + + + + {viewEncoding === ViewEncoding.UTF8 ? utf8Message : hexMessage} + + + + + + + + ) + })} + + + + + + ) +} diff --git a/apps/extension/src/app/features/home/TokenBalanceList.tsx b/apps/extension/src/app/features/home/TokenBalanceList.tsx new file mode 100644 index 00000000000..5b8f069107f --- /dev/null +++ b/apps/extension/src/app/features/home/TokenBalanceList.tsx @@ -0,0 +1,189 @@ +import { SharedEventName } from '@uniswap/analytics-events' +import { PropsWithChildren, memo } from 'react' +import { useTranslation } from 'react-i18next' +import { useInterfaceBuyNavigator } from 'src/app/features/for/utils' +import { AppRoutes } from 'src/app/navigation/constants' +import { navigate } from 'src/app/navigation/state' +import { AnimatePresence, ContextMenu, Flex, Loader } from 'ui/src' +import { BaseCard } from 'uniswap/src/components/BaseCard/BaseCard' +import { PortfolioBalance } from 'uniswap/src/features/dataApi/types' +import { ElementName, SectionName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { useWalletNavigation } from 'wallet/src/contexts/WalletNavigationContext' +import { isNonPollingRequestInFlight } from 'wallet/src/data/utils' +import { HiddenTokensRow } from 'wallet/src/features/portfolio/HiddenTokensRow' +import { PortfolioEmptyState } from 'wallet/src/features/portfolio/PortfolioEmptyState' +import { TokenBalanceItem } from 'wallet/src/features/portfolio/TokenBalanceItem' +import { + HIDDEN_TOKEN_BALANCES_ROW, + TokenBalanceListContextProvider, + TokenBalanceListRow, + useTokenBalanceListContext, +} from 'wallet/src/features/portfolio/TokenBalanceListContext' +import { useTokenContextMenu } from 'wallet/src/features/portfolio/useTokenContextMenu' + +const MIN_CONTEXT_MENU_WIDTH = 200 + +type TokenBalanceListProps = { + owner: Address +} + +export const TokenBalanceList = memo(function _TokenBalanceList({ owner }: TokenBalanceListProps): JSX.Element { + const { navigateToTokenDetails } = useWalletNavigation() + + const onPressToken = (currencyId: string): void => { + sendAnalyticsEvent(SharedEventName.ELEMENT_CLICKED, { + element: ElementName.TokenItem, + section: SectionName.HomeTokensTab, + }) + navigateToTokenDetails(currencyId) + } + + return ( + + + + + + ) +}) + +export function TokenBalanceListInner(): JSX.Element { + const { t } = useTranslation() + + const { rows, balancesById, networkStatus, refetch, hiddenTokensExpanded } = useTokenBalanceListContext() + const onPressBuy = useInterfaceBuyNavigator(ElementName.EmptyStateBuy) + + const visible: string[] = [] + const hidden: string[] = [] + + let isHidden = false + for (const row of rows) { + const target = isHidden ? hidden : visible + target.push(row) + // do this after pushing so we keep our Hidden header row in the visible section + // so users can see it when closed and re-open it + if (row === HIDDEN_TOKEN_BALANCES_ROW) { + isHidden = true + } + } + + const onPressReceive = (): void => { + navigate(AppRoutes.Receive) + } + + return ( + + {!balancesById ? ( + isNonPollingRequestInFlight(networkStatus) ? ( + + + + ) : ( + + refetch?.()} + /> + + ) + ) : rows.length === 0 ? ( + + ) : ( + <> + + + {hiddenTokensExpanded && } + + + )} + + ) +} + +const TokenBalanceItems = ({ animated, rows }: { animated?: boolean; rows: string[] }): JSX.Element => { + return ( + + {rows?.map((balance: TokenBalanceListRow) => { + return + })} + + ) +} + +const TokenBalanceItemRow = memo(function TokenBalanceItemRow({ item }: { item: TokenBalanceListRow }) { + const { + balancesById, + hiddenTokensCount, + hiddenTokensExpanded, + isWarmLoading, + onPressToken, + setHiddenTokensExpanded, + } = useTokenBalanceListContext() + + if (item === HIDDEN_TOKEN_BALANCES_ROW) { + return ( + { + setHiddenTokensExpanded(!hiddenTokensExpanded) + }} + /> + ) + } + + const portfolioBalance = balancesById?.[item] + + if (!portfolioBalance) { + // This can happen when the view is out of focus and the user sells/sends 100% of a token's balance. + // In that case, the token is removed from the `balancesById` object, but the FlatList is still using the cached array of IDs until the view comes back into focus. + // As soon as the view comes back into focus, the FlatList will re-render with the latest data, so users won't really see this Skeleton for more than a few milliseconds when this happens. + return ( + + + + ) + } + + return ( + + + + ) +}) + +function TokenContextMenu({ + children, + portfolioBalance, +}: PropsWithChildren<{ + portfolioBalance: PortfolioBalance +}>): JSX.Element { + const contextMenu = useTokenContextMenu({ + currencyId: portfolioBalance.currencyInfo.currencyId, + tokenSymbolForNotification: portfolioBalance?.currencyInfo?.currency?.symbol, + portfolioBalance, + }) + + const menuOptions = contextMenu.menuActions.map((action) => ({ + label: action.title, + onPress: action.onPress, + Icon: action.Icon, + destructive: action.destructive, + })) + + const itemId = `${portfolioBalance.currencyInfo.currencyId}-${portfolioBalance.isHidden}` + + return ( + + {children} + + ) +} diff --git a/apps/extension/src/app/features/lockScreen/Locked.tsx b/apps/extension/src/app/features/lockScreen/Locked.tsx new file mode 100644 index 00000000000..61ebbe0baa9 --- /dev/null +++ b/apps/extension/src/app/features/lockScreen/Locked.tsx @@ -0,0 +1,256 @@ +import { useCallback, useLayoutEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Input } from 'src/app/components/Input' +import { PasswordInput } from 'src/app/components/PasswordInput' +import { BottomModalProps, InfoModal } from 'src/app/components/modal/InfoModal' +import { useSagaStatus } from 'src/app/hooks/useSagaStatus' +import { OnboardingRoutes, TopLevelRoutes } from 'src/app/navigation/constants' +import { focusOrCreateOnboardingTab } from 'src/app/navigation/utils' +import { useAppDispatch } from 'src/store/store' +import { Button, Flex, InputProps, Text, TouchableArea } from 'ui/src' +import { AlertTriangle, Lock } from 'ui/src/components/icons' +import { spacing, zIndices } from 'ui/src/theme' +import { uniswapUrls } from 'uniswap/src/constants/urls' +import { FeatureFlags } from 'uniswap/src/features/gating/flags' +import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { LandingBackground } from 'wallet/src/components/landing/LandingBackground' +import { authActions, authSagaName } from 'wallet/src/features/auth/saga' +import { AuthActionType, AuthSagaError } from 'wallet/src/features/auth/types' +import { Keyring } from 'wallet/src/features/wallet/Keyring/Keyring' +import { EditAccountAction, editAccountActions } from 'wallet/src/features/wallet/accounts/editAccountSaga' +import { useSignerAccounts } from 'wallet/src/features/wallet/hooks' +import { SagaStatus } from 'wallet/src/utils/saga' + +export function usePasswordInput(defaultValue = ''): Pick & { value: string } { + const [value, setValue] = useState(defaultValue) + + const onChangeText: InputProps['onChangeText'] = (newValue): void => { + setValue(newValue) + } + + return { + value, + disabled: !value, + onChangeText, + } +} + +enum ForgotPasswordModalStep { + Initial, + Speedbump, +} + +const CONTAINER_PADDING_TOP_MIN = 50 +const CONTAINER_PADDING_TOP_MAX = 220 +const BACKGROUND_CIRCLE_INNER_SIZE = 140 +const BACKGROUND_CIRCLE_OUTER_SIZE = 250 + +export function Locked(): JSX.Element { + const dispatch = useAppDispatch() + const { t } = useTranslation() + const { value: enteredPassword, onChangeText: onChangePasswordText } = usePasswordInput() + const associatedAccounts = useSignerAccounts() + + const onChangeText = useCallback( + (text: string) => { + if (onChangePasswordText) { + onChangePasswordText?.(text) + } + }, + [onChangePasswordText], + ) + + const { status, error } = useSagaStatus(authSagaName, undefined, false) + + const onPress = async (): Promise => { + await dispatch( + authActions.trigger({ + type: AuthActionType.Unlock, + password: enteredPassword, + }), + ) + } + + const [forgotPasswordModalOpen, setForgotPasswordModalOpen] = useState(false) + const [modalStep, setModalStep] = useState(ForgotPasswordModalStep.Initial) + const scantasticOnboardingOnly = useFeatureFlag(FeatureFlags.ScantasticOnboardingOnly) + + const openRecoveryTab = (): Promise => + focusOrCreateOnboardingTab( + `${TopLevelRoutes.Onboarding}/${scantasticOnboardingOnly ? OnboardingRoutes.ResetScan : OnboardingRoutes.Reset}`, + ) + + const onStartResettingWallet = async (): Promise => { + const currAccount = associatedAccounts[0] + + if (currAccount?.mnemonicId) { + await Keyring.removeMnemonic(currAccount?.mnemonicId) + } + await Keyring.removePassword() + + // We open the recovery tab before removing the accounts so that the proper reset route is loaded. + // Otherwise, the main onboarding route is automatically loaded when accounts are all removed, and then a duplicate recovery tab is opened. + // The standard onboarding open logic triggers but doesn't update the path because the generic one doesn't have a path specified. + await openRecoveryTab() + + await dispatch( + editAccountActions.trigger({ + type: EditAccountAction.Remove, + accounts: associatedAccounts, + }), + ) + } + + const isIncorrectPassword = status === SagaStatus.Failure && error === AuthSagaError.InvalidPassword + + const inputRef = useRef(null) + const [hideInput, setHideInput] = useState(true) + const toggleHideInput = (): void => setHideInput(!hideInput) + + useLayoutEffect(() => { + if (isIncorrectPassword) { + inputRef.current?.focus() + } + }, [isIncorrectPassword]) + + const modalProps: Record = { + [ForgotPasswordModalStep.Initial]: { + buttonText: t('extension.lock.button.reset'), + description: t('extension.lock.password.reset.initial.description'), + linkText: t('extension.lock.password.reset.initial.help'), + linkUrl: uniswapUrls.helpArticleUrls.recoveryPhraseHowToFind, + icon: ( + + + + ), + isOpen: forgotPasswordModalOpen, + name: ModalName.ForgotPassword, + onButtonPress: (): void => setModalStep(ForgotPasswordModalStep.Speedbump), + title: t('extension.lock.password.reset.initial.title'), + }, + [ForgotPasswordModalStep.Speedbump]: { + buttonText: t('common.button.continue'), + description: t('extension.lock.password.reset.speedbump.description'), + linkText: t('extension.lock.password.reset.speedbump.help'), + linkUrl: uniswapUrls.helpArticleUrls.recoveryPhraseForgotten, + icon: ( + + + + ), + isOpen: forgotPasswordModalOpen, + name: ModalName.ForgotPassword, + onButtonPress: onStartResettingWallet, + title: t('extension.lock.password.reset.speedbump.title'), + }, + } + + const [inputHeight, setInputHeight] = useState(0) + const [containerPaddingTop, setContainerPaddingTop] = useState(CONTAINER_PADDING_TOP_MAX) + const [availableHeight, setAvailableHeight] = useState(0) + + useLayoutEffect(() => { + if (availableHeight && inputHeight) { + const containerHeight = inputHeight + spacing.spacing32 + const newPaddingTop = Math.min( + Math.max(CONTAINER_PADDING_TOP_MIN, availableHeight - containerHeight), + CONTAINER_PADDING_TOP_MAX, + ) + + setContainerPaddingTop(newPaddingTop) + } + }, [availableHeight, inputHeight]) + + return ( + <> + + setAvailableHeight(e.nativeEvent.layout.height)}> + + + + setInputHeight(e.nativeEvent.layout.height)} + > + + + {t('extension.lock.title')} + + + + {t('extension.lock.subtitle')} + + + + + + + + + {t('extension.lock.password.error')} + + + + + + + + + + + setForgotPasswordModalOpen(true)} + > + {t('extension.lock.button.forgot')} + + + + + + { + setModalStep(ForgotPasswordModalStep.Initial) + setForgotPasswordModalOpen(false) + }} + /> + + ) +} diff --git a/apps/extension/src/app/features/notifications/NotificationToastWrapper.tsx b/apps/extension/src/app/features/notifications/NotificationToastWrapper.tsx new file mode 100644 index 00000000000..5bfb74defb2 --- /dev/null +++ b/apps/extension/src/app/features/notifications/NotificationToastWrapper.tsx @@ -0,0 +1,36 @@ +import { DappConnectedNotification } from 'wallet/src/features/notifications/components/DappConnectedNotification' +import { DappDisconnectedNotification } from 'wallet/src/features/notifications/components/DappDisconnectedNotification' +import { NotSupportedNetworkNotification } from 'wallet/src/features/notifications/components/NotSupportedNetworkNotification' +import { PasswordChangedNotification } from 'wallet/src/features/notifications/components/PasswordChangedNotification' +import { SharedNotificationToastRouter } from 'wallet/src/features/notifications/components/SharedNotificationToastRouter' +import { selectActiveAccountNotifications } from 'wallet/src/features/notifications/selectors' +import { AppNotification, AppNotificationType } from 'wallet/src/features/notifications/types' +import { useAppSelector } from 'wallet/src/state' + +export function NotificationToastWrapper(): JSX.Element | null { + const notifications = useAppSelector(selectActiveAccountNotifications) + const notification = notifications?.[0] + + if (!notification) { + return null + } + + return +} + +function NotificationToastRouter({ notification }: { notification: AppNotification }): JSX.Element | null { + // Insert Extension-only notifications here. + // Shared wallet notifications should go in SharedNotificationToastRouter. + switch (notification.type) { + case AppNotificationType.DappConnected: + return + case AppNotificationType.NotSupportedNetwork: + return + case AppNotificationType.DappDisconnected: + return + case AppNotificationType.PasswordChanged: + return + } + + return +} diff --git a/apps/extension/src/app/features/onboarding/Complete.tsx b/apps/extension/src/app/features/onboarding/Complete.tsx new file mode 100644 index 00000000000..dc13162a5e3 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/Complete.tsx @@ -0,0 +1,88 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { KeyboardKey } from 'src/app/features/onboarding/KeyboardKey' +import { MainContentWrapper } from 'src/app/features/onboarding/intro/MainContentWrapper' +import { useOpeningKeyboardShortCut } from 'src/app/hooks/useOpeningKeyboardShortCut' +import { getCurrentTabAndWindowId } from 'src/app/navigation/utils' +import { onboardingMessageChannel } from 'src/background/messagePassing/messageChannels' +import { OnboardingMessageType } from 'src/background/messagePassing/types/ExtensionMessages' +import { openSidePanel } from 'src/background/utils/chromeSidePanelUtils' +import { terminateStoreSynchronization } from 'src/store/storeSynchronization' +import { Button, Flex, Image, Text } from 'ui/src' +import { UNISWAP_LOGO } from 'ui/src/assets' +import { RightArrow } from 'ui/src/components/icons' +import { iconSizes } from 'ui/src/theme' +import { uniswapUrls } from 'uniswap/src/constants/urls' +import { ExtensionOnboardingFlow } from 'uniswap/src/types/screens/extension' +import { logger } from 'utilities/src/logger/logger' +import { useFinishOnboarding } from 'wallet/src/features/onboarding/OnboardingContext' + +export function Complete({ flow }: { flow?: ExtensionOnboardingFlow }): JSX.Element { + const { t } = useTranslation() + + const [openedSideBar, setOpenedSideBar] = useState(false) + + // Activates onboarding accounts on component mount + useFinishOnboarding(terminateStoreSynchronization, flow) + + useEffect(() => { + const onSidebarOpenedListener = onboardingMessageChannel.addMessageListener( + OnboardingMessageType.SidebarOpened, + (_message) => { + setOpenedSideBar(true) + }, + ) + return () => { + onboardingMessageChannel.removeMessageListener(OnboardingMessageType.SidebarOpened, onSidebarOpenedListener) + } + }, []) + + const handleOpenWebApp = async (): Promise => { + window.location.href = uniswapUrls.webInterfaceSwapUrl + } + + const handleOpenSidebar = async (): Promise => { + try { + const { tabId, windowId } = await getCurrentTabAndWindowId() + await openSidePanel(tabId, windowId) + } catch (error) { + logger.error(error, { + tags: { file: 'onboarding/Complete.tsx', function: 'handleOpenSidebar' }, + }) + } + } + + const keys = useOpeningKeyboardShortCut(openedSideBar) + + return ( + + + + + + + {t('onboarding.complete.title')} + + + {t('onboarding.complete.description')} + + + + {keys.map((key) => ( + + ))} + + + + + + ) +} diff --git a/apps/extension/src/app/features/onboarding/KeyboardKey.test.tsx b/apps/extension/src/app/features/onboarding/KeyboardKey.test.tsx new file mode 100644 index 00000000000..04692bdea3a --- /dev/null +++ b/apps/extension/src/app/features/onboarding/KeyboardKey.test.tsx @@ -0,0 +1,33 @@ +import { KeyboardKey } from 'src/app/features/onboarding/KeyboardKey' +import { State } from 'src/app/hooks/useOpeningKeyboardShortCut' +import { cleanup, render, screen } from 'src/test/test-utils' + +describe('KeyboardKey Component', () => { + it('renders correctly with state KeyUp', () => { + const { container } = render() + expect(container).toMatchSnapshot() + }) + + it('renders correctly with state KeyDown', () => { + const { container } = render() + expect(container).toMatchSnapshot() + }) + + it('renders correctly with state Highlighted', () => { + const { container } = render() + expect(container).toMatchSnapshot() + cleanup() + }) + + it('displays the command symbol for Meta key on macOS', () => { + render() + expect(screen.getByText('⌘')).toBeDefined() + cleanup() + }) + + it('displays the correct title for other keys', () => { + render() + expect(screen.getByText('U')).toBeDefined() + cleanup() + }) +}) diff --git a/apps/extension/src/app/features/onboarding/KeyboardKey.tsx b/apps/extension/src/app/features/onboarding/KeyboardKey.tsx new file mode 100644 index 00000000000..e77deaba5bf --- /dev/null +++ b/apps/extension/src/app/features/onboarding/KeyboardKey.tsx @@ -0,0 +1,40 @@ +import { Flex, Text } from 'ui/src' +const SHADOW_OFFSET = { width: 0, height: 7 } +const MAC_OS_COMMAND_SYMBOL = '⌘' +const KEY_HEIGHT = 70 + +enum State { + KeyUp, + KeyDown, + Highlighted, +} + +export interface KeyboardKeyProps { + title: string + px: React.ComponentProps['px'] + fontSize: React.ComponentProps['fontSize'] + state: State +} + +export function KeyboardKey({ title, px, fontSize, state }: KeyboardKeyProps): JSX.Element { + return ( + + + {title === 'Meta' ? MAC_OS_COMMAND_SYMBOL : title} + + + ) +} diff --git a/apps/extension/src/app/features/onboarding/OnboardingPaneAnimatedContents.tsx b/apps/extension/src/app/features/onboarding/OnboardingPaneAnimatedContents.tsx new file mode 100644 index 00000000000..3f061c69ef0 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/OnboardingPaneAnimatedContents.tsx @@ -0,0 +1,34 @@ +import { Flex, styled } from 'ui/src' + +const SINGLE_PANE_DURATION = 200 + +// TODO: EXT-1164 - Move Keyring methods to workers to not block main thread during onboarding +// if exitBeforeEnter is set in the AnimatePresence we are +// running two 200ms animations sequentially - first to exit, then enter so we +// double this constant. if we change that, needs to change here +export const ONBOARDING_PANE_TRANSITION_DURATION = SINGLE_PANE_DURATION * 2 +export const ONBOARDING_PANE_TRANSITION_DURATION_WITH_LEEWAY = ONBOARDING_PANE_TRANSITION_DURATION + 200 + +export const OnboardingPaneAnimatedContents = styled(Flex, { + animation: `${SINGLE_PANE_DURATION}ms`, + width: '100%', + + zIndex: 1, + x: 0, + opacity: 1, + mx: 'auto', + + variants: { + // note you can use _towards for implementing animations based on the direction! + going: (_towards: 'forward' | 'backward') => ({ + enterStyle: { + opacity: 0, + zIndex: 1, + }, + exitStyle: { + zIndex: 0, + opacity: 0, + }, + }), + } as const, +}) diff --git a/apps/extension/src/app/features/onboarding/OnboardingScreen.tsx b/apps/extension/src/app/features/onboarding/OnboardingScreen.tsx new file mode 100644 index 00000000000..2f012296425 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/OnboardingScreen.tsx @@ -0,0 +1,20 @@ +import { useContext, useLayoutEffect } from 'react' +import { OnboardingScreenProps } from 'src/app/features/onboarding/OnboardingScreenProps' +import { OnboardingStepsContext } from 'src/app/features/onboarding/OnboardingStepsContext' + +export function OnboardingScreen(props: OnboardingScreenProps): null { + const context = useContext(OnboardingStepsContext) + + useLayoutEffect(() => { + if (!context) { + return + } + context.setOnboardingScreen(props) + return () => { + context.clearOnboardingScreen(props) + } + }, [context, props]) + + // we hoist it up, see OnboardingSteps + OnboardingScreenFrame + return null +} diff --git a/apps/extension/src/app/features/onboarding/OnboardingScreenFrame.tsx b/apps/extension/src/app/features/onboarding/OnboardingScreenFrame.tsx new file mode 100644 index 00000000000..e8bbd4a98c1 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/OnboardingScreenFrame.tsx @@ -0,0 +1,84 @@ +import { OnboardingScreenProps } from 'src/app/features/onboarding/OnboardingScreenProps' +import { Button, Flex, Text, TouchableArea } from 'ui/src' +import { BackArrow } from 'ui/src/components/icons' +import i18n from 'uniswap/src/i18n/i18n' + +export function OnboardingScreenFrame({ + Icon, + children, + nextButtonEnabled, + nextButtonText = i18n.t('common.button.next'), + nextButtonTheme = 'primary', + onBack, + onSubmit, + onSkip, + subtitle, + title, + warningSubtitle, +}: Partial): JSX.Element { + if (!title) { + return <>{children} + } + + return ( + <> + + {onBack && ( + + + + )} + {onSkip && ( + + + Skip + + + )} + {Icon} + + + {title} + + + + {subtitle} + + {warningSubtitle && ( + + {warningSubtitle} + + )} + + + + + {children} + + + {Boolean(onSubmit) && nextButtonText && ( + + )} + + + ) +} diff --git a/apps/extension/src/app/features/onboarding/OnboardingScreenProps.tsx b/apps/extension/src/app/features/onboarding/OnboardingScreenProps.tsx new file mode 100644 index 00000000000..af3ad90dfb8 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/OnboardingScreenProps.tsx @@ -0,0 +1,17 @@ +import { ThemeNames } from 'ui/src/theme' + +export type OnboardingScreenProps = { + Icon?: JSX.Element + children?: JSX.Element + nextButtonEnabled?: boolean + nextButtonText?: string + nextButtonTheme?: ThemeNames + onBack?: () => void + onSubmit?: () => void + onSkip?: () => void + subtitle?: string + title: string | JSX.Element + warningSubtitle?: string + outsideContent?: JSX.Element + belowFrameContent?: JSX.Element +} diff --git a/apps/extension/src/app/features/onboarding/OnboardingSteps.tsx b/apps/extension/src/app/features/onboarding/OnboardingSteps.tsx new file mode 100644 index 00000000000..8b5548c3482 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/OnboardingSteps.tsx @@ -0,0 +1,304 @@ +import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { OnboardingPaneAnimatedContents } from 'src/app/features/onboarding/OnboardingPaneAnimatedContents' +import { OnboardingScreenFrame } from 'src/app/features/onboarding/OnboardingScreenFrame' +import { OnboardingScreenProps } from 'src/app/features/onboarding/OnboardingScreenProps' +import { + OnboardingStepsContext, + OnboardingStepsContextState, + Step, +} from 'src/app/features/onboarding/OnboardingStepsContext' +import { ONBOARDING_CONTENT_WIDTH, ONBOARDING_INITIAL_FRAME_HEIGHT } from 'src/app/features/onboarding/utils' +import { TopLevelRoutes } from 'src/app/navigation/constants' +import { navigate } from 'src/app/navigation/state' +import { isOnboardedSelector } from 'src/app/utils/isOnboardedSelector' +import { AnimatePresence, Flex, styled, useWindowDimensions } from 'ui/src' +import { useAppSelector } from 'wallet/src/state' + +export * from './OnboardingStepsContext' + +type ComponentByStep = { [key in Step]?: JSX.Element } +type MaybeOnboardingProps = OnboardingScreenProps | null + +/** + * In this file we're doing some weird stuff because we want to keep a nice API + * for onboarding screens but also allow animating them, while still working + * with react router. + * + * AnimatePresence wants to be able to swap out old for new, but react router + * wants to handle that as well + * + * So we have to hoist the props of up to here. + * + * But doing that could cause a re-render loop if the child component isn't + * careful to memoize things. So, we've implemented a little pattern here to + * avoid that - instead of re-rendering the entire OnboardingStepsProvider + * whenever a child re-renders, we instead have a simple emitter/listener we + * trigger (onboardingScreenListen) and we the re-render the contents in a + * sub-component OnboardingScreenDisplay. This way OnboardingScreenDisplay can + * re-render as much as it wants and it doesn't cause the child to re-render, + * avoiding loops! + */ + +let currentOnboardingScreen: MaybeOnboardingProps = null +const onboardingScreenListen = new Set<(step: Step, val: MaybeOnboardingProps) => void>() + +let clearScreenTimeout: NodeJS.Timeout + +export function OnboardingStepsProvider({ + steps, + isResetting = false, + ContainerComponent = React.Fragment, +}: { + steps: ComponentByStep + isResetting?: boolean + ContainerComponent?: React.ComponentType +}): JSX.Element { + const isOnboarded = useAppSelector(isOnboardedSelector) + const wasAlreadyOnboardedWhenPageLoaded = useRef(isOnboarded) + + useEffect(() => { + if (!isResetting && wasAlreadyOnboardedWhenPageLoaded.current) { + // Redirect to the intro screen screen if user is already onboarded. + // We only want to redirect when the page is first loaded but not immediately after the user completes onboarding. + navigate(`/${TopLevelRoutes.Onboarding}`, { replace: true }) + } + }, [isOnboarded, isResetting]) + + const initialStep = Object.keys(steps)[0] as Step + + if (!initialStep) { + throw new Error('`steps` must have at least one `step`') + } + + const [{ step, going, onboardingScreen }, setState] = useState<{ + onboardingScreen?: MaybeOnboardingProps + step: Step + going: 'forward' | 'backward' + }>({ + step: initialStep, + going: 'forward', + }) + + const getCurrentStep = useRef(step) + getCurrentStep.current = step + + const setStep = useCallback((nextStep: Step) => { + setState((prev) => ({ ...prev, step: nextStep })) + }, []) + + const setOnboardingScreen = useCallback((next: OnboardingScreenProps) => { + clearTimeout(clearScreenTimeout) + setState((prev) => { + // we are only updating onboardingScreen here once per unique title so + // the state in this component is accurate, but subsequent updates go + // through the emitter + if (onboardingScreenKey(prev?.onboardingScreen) !== onboardingScreenKey(next)) { + return { + ...prev, + onboardingScreen: next, + } + } + return prev + }) + onboardingScreenListen.forEach((cb) => cb(getCurrentStep.current, next)) + currentOnboardingScreen = next + }, []) + + const clearOnboardingScreen = useCallback((next: OnboardingScreenProps) => { + // delay clear so the next screen can beat clearing the old one to avoid flickering + clearScreenTimeout = setTimeout(() => { + setState((prev) => { + if (prev.onboardingScreen && onboardingScreenKey(prev.onboardingScreen) === onboardingScreenKey(next)) { + return { + ...prev, + onboardingScreen: null, + } + } + return prev + }) + }) + }, []) + + const onboardingScreenKey = (props?: MaybeOnboardingProps): string => { + return `${props?.title}${props?.subtitle}${Object.keys(props || {}).join('')}` + } + + const goToNextStep = useCallback(() => { + const stepIndex = Object.keys(steps).indexOf(step) + const nextStep = Object.keys(steps)[stepIndex + 1] as Step + + if (!nextStep) { + throw new Error('No next step') + } + + setState((prev) => ({ + ...prev, + step: nextStep, + going: 'forward', + })) + }, [step, steps]) + + const goToPreviousStep = useCallback(() => { + const stepIndex = Object.keys(steps).indexOf(step) + const previousStep = Object.keys(steps)[stepIndex - 1] as Step + + if (!previousStep) { + throw new Error('No previous step') + } + + setState((prev) => ({ + ...prev, + step: previousStep, + going: 'backward', + })) + }, [step, steps]) + + const state = useMemo((): OnboardingStepsContextState => { + return { + step, + setStep, + goToNextStep, + setOnboardingScreen, + clearOnboardingScreen, + goToPreviousStep, + isResetting, + going, + } + }, [step, setStep, goToNextStep, setOnboardingScreen, clearOnboardingScreen, goToPreviousStep, isResetting, going]) + + const stepContents = steps[step] + const [frameHeight, setFrameHeight] = useState(ONBOARDING_INITIAL_FRAME_HEIGHT) + const windowDimensions = useWindowDimensions() + const modalY = windowDimensions.height / 2 - frameHeight / 2 + const hasBelowFrameContent = Boolean(onboardingScreen?.belowFrameContent) + const [belowFrameHeight, setBelowFrameHeight] = useState(-1) + const y = + modalY + + // ensure vertically centered when belowFrameContent exists + (hasBelowFrameContent + ? -(belowFrameHeight === -1 + ? // estimate the content height before measurement + 63 + : belowFrameHeight) + 30 + : 0) + + if (!stepContents) { + throw new Error(`Unknown step: ${step}`) + } + + return ( + + + {!onboardingScreen && <>{stepContents}} + + {/* render the contents from step here */} + {onboardingScreen && ( + <> + {/* render actual screen contents "offscreen", we use context and put it on onboardingScreen */} +
{stepContents}
+ { + setFrameHeight(e.nativeEvent.layout.height) + }} + > + + + {/** + * animate the inner contents of the onboarding steps modal + * exitBeforeEnter because we are keeping things simpler and having the inner contents + * not be absolutely positioned, which would let us do overlapping animations but we'd have + * to measure dimensions and do some delicate state management around that. + */} + + {/* note: the exitBeforeEnter here affects the constant ONBOARDING_PANE_TRANSITION_DURATION in OnboardingPaneAnimatedContents.tsx */} + + + + + + + + {hasBelowFrameContent && ( + setBelowFrameHeight(e.nativeEvent.layout.height)} + > + {onboardingScreen?.belowFrameContent} + + )} + + + )} + + {onboardingScreen?.outsideContent || null} +
+
+ ) +} + +const OnboardingScreenDisplay = memo(function OnboardingScreenDisplay(props: { step: Step }): JSX.Element { + const [state, setState] = useState(currentOnboardingScreen) + + useEffect(() => { + const handler = (step: Step, next: MaybeOnboardingProps): void => { + if (step === props.step) { + setState(next) + } + } + + onboardingScreenListen.add(handler) + return () => { + onboardingScreenListen.delete(handler) + } + }, [props.step]) + + return +}) + +// containing frame just for positioning +const Frame = styled(Flex, { + position: 'absolute', + top: 0, + left: '50%', + x: -ONBOARDING_CONTENT_WIDTH * 0.5, + alignItems: 'center', + justifyContent: 'center', + width: ONBOARDING_CONTENT_WIDTH, +}) + +// separate frame background so we can animate +const FrameBackground = styled(Flex, { + position: 'absolute', + top: 0, + left: 0, + width: '100%', + backgroundColor: '$surface1', + borderColor: '$surface3', + borderRadius: '$rounded32', + borderWidth: '$spacing1', + shadowRadius: 4, + shadowColor: '$shadowColor', + shadowOffset: { + height: 2, + width: 0, + }, + shadowOpacity: 0.25, +}) + +// inner frame to prevent overflow of outer frame +const FrameInner = styled(Flex, { + height: '100%', + overflow: 'hidden', + width: '100%', + borderRadius: '$rounded32', + gap: '$spacing12', + pb: '$spacing24', + pt: '$spacing24', + px: '$spacing24', +}) diff --git a/apps/extension/src/app/features/onboarding/OnboardingStepsContext.tsx b/apps/extension/src/app/features/onboarding/OnboardingStepsContext.tsx new file mode 100644 index 00000000000..e1bba51637f --- /dev/null +++ b/apps/extension/src/app/features/onboarding/OnboardingStepsContext.tsx @@ -0,0 +1,58 @@ +import { createContext, useContext } from 'react' +import { OnboardingScreenProps } from 'src/app/features/onboarding/OnboardingScreenProps' + +export enum CreateOnboardingSteps { + Password = 'password', + ViewMnemonic = 'mnemonic', + TestMnemonic = 'testMnemonic', + Naming = 'naming', + Complete = 'complete', +} + +export enum ImportOnboardingSteps { + Mnemonic = 'mnemonic', + Password = 'password', + Select = 'select', + Backup = 'backup', + Complete = 'complete', +} + +export enum ResetSteps { + Mnemonic = 'mnemonic', + Password = 'password', + Complete = 'complete', + Select = 'select', +} + +export enum ScanOnboardingSteps { + Password = 'password', + Scan = 'scan', + OTP = 'otp', + Select = 'select', + Complete = 'complete', +} + +export type Step = CreateOnboardingSteps | ImportOnboardingSteps | ResetSteps | ScanOnboardingSteps + +export type OnboardingStepsContextState = { + step: Step + going?: 'forward' | 'backward' + setStep: (step: Step) => void + setOnboardingScreen: (screen: OnboardingScreenProps) => void + clearOnboardingScreen: (screen: OnboardingScreenProps) => void + goToNextStep: () => void + goToPreviousStep: () => void + isResetting: boolean +} + +export const OnboardingStepsContext = createContext(undefined) + +export function useOnboardingSteps(): OnboardingStepsContextState { + const onboardingStepsContext = useContext(OnboardingStepsContext) + + if (onboardingStepsContext === undefined) { + throw new Error('`useOnboardingSteps` must be used inside of `OnboardingStepsProvider`') + } + + return onboardingStepsContext +} diff --git a/apps/extension/src/app/features/onboarding/OnboardingWrapper.tsx b/apps/extension/src/app/features/onboarding/OnboardingWrapper.tsx new file mode 100644 index 00000000000..7e5c3c5ea6e --- /dev/null +++ b/apps/extension/src/app/features/onboarding/OnboardingWrapper.tsx @@ -0,0 +1,54 @@ +import { useEffect, useState } from 'react' +import { Outlet } from 'react-router-dom' +import { StorageWarningModal } from 'src/app/features/warnings/StorageWarningModal' +import { ONBOARDING_BACKGROUND_DARK, ONBOARDING_BACKGROUND_LIGHT } from 'src/assets' +import { onboardingMessageChannel } from 'src/background/messagePassing/messageChannels' +import { OnboardingMessageType } from 'src/background/messagePassing/types/ExtensionMessages' +import { Flex, Image, useIsDarkMode } from 'ui/src' +import { syncAppWithDeviceLanguage } from 'wallet/src/features/language/slice' +import { OnboardingContextProvider } from 'wallet/src/features/onboarding/OnboardingContext' +import { useAppDispatch } from 'wallet/src/state' + +export function OnboardingWrapper(): JSX.Element { + const isDarkMode = useIsDarkMode() + const [isHighlighted, setIsHighlighted] = useState(false) + const dispatch = useAppDispatch() + + useEffect(() => { + dispatch(syncAppWithDeviceLanguage()) + }, [dispatch]) + + useEffect(() => { + return onboardingMessageChannel.addMessageListener(OnboardingMessageType.HighlightOnboardingTab, (_message) => { + // When the onboarding tab regains focus, we do a quick background change to bring attention to it. + // Otherwise, the user might not notice that the tab is now active, specially if the tab is on a different monitor. + setIsHighlighted(true) + setTimeout(() => setIsHighlighted(false), 200) + }) + }, []) + + return ( + + + + {/* TODO: Update this to use the new background asset with varying blur level */} + {!isHighlighted && ( + + )} + + + + ) +} diff --git a/apps/extension/src/app/features/onboarding/Password.tsx b/apps/extension/src/app/features/onboarding/Password.tsx new file mode 100644 index 00000000000..9eee86e24b8 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/Password.tsx @@ -0,0 +1,110 @@ +import { useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { PADDING_STRENGTH_INDICATOR, PasswordInput } from 'src/app/components/PasswordInput' +import { OnboardingScreen } from 'src/app/features/onboarding/OnboardingScreen' +import { useOnboardingSteps } from 'src/app/features/onboarding/OnboardingSteps' +import { TopLevelRoutes } from 'src/app/navigation/constants' +import { navigate } from 'src/app/navigation/state' +import { Flex, Square, Text } from 'ui/src' +import { Lock } from 'ui/src/components/icons' +import { iconSizes } from 'ui/src/theme' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { ExtensionOnboardingFlow, ExtensionOnboardingScreens } from 'uniswap/src/types/screens/extension' +import { useOnboardingContext } from 'wallet/src/features/onboarding/OnboardingContext' +import { usePasswordForm } from 'wallet/src/utils/password' + +export function Password({ + flow, + onComplete, + onBack, +}: { + flow: ExtensionOnboardingFlow + onComplete: (password: string) => Promise + onBack?: () => void +}): JSX.Element { + const { t } = useTranslation() + const { isResetting } = useOnboardingSteps() + const { resetOnboardingContextData } = useOnboardingContext() + + const { + enableNext, + hideInput, + debouncedPasswordStrength, + password, + onPasswordBlur, + onChangePassword, + confirmPassword, + onChangeConfirmPassword, + setHideInput, + errorText, + checkSubmit, + } = usePasswordForm() + + const onSubmit = useCallback(async () => { + if (checkSubmit()) { + await onComplete(password) + } + }, [onComplete, password, checkSubmit]) + + const handleBack = useCallback(() => { + // reset the pending mnemonic when going back from password screen + // to avoid having them in the context when coming back to either screen + resetOnboardingContextData() + if (onBack) { + onBack() + } else { + navigate(`/${TopLevelRoutes.Onboarding}`, { replace: true }) + } + }, [onBack, resetOnboardingContextData]) + + return ( + + + + + } + nextButtonEnabled={enableNext} + nextButtonText={t('common.button.continue')} + subtitle={t('onboarding.extension.password.subtitle')} + title={ + isResetting + ? t('onboarding.extension.password.title.reset') + : t('onboarding.extension.password.title.default') + } + onBack={handleBack} + onSubmit={onSubmit} + > + + + + + {errorText || 'Placeholder text'} + + + + + ) +} diff --git a/apps/extension/src/app/features/onboarding/PasswordImport.tsx b/apps/extension/src/app/features/onboarding/PasswordImport.tsx new file mode 100644 index 00000000000..256bd937f57 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/PasswordImport.tsx @@ -0,0 +1,43 @@ +import { useCallback } from 'react' +import { ONBOARDING_PANE_TRANSITION_DURATION_WITH_LEEWAY } from 'src/app/features/onboarding/OnboardingPaneAnimatedContents' +import { useOnboardingSteps } from 'src/app/features/onboarding/OnboardingSteps' +import { Password } from 'src/app/features/onboarding/Password' +import { ExtensionOnboardingFlow } from 'uniswap/src/types/screens/extension' +import { sleep } from 'utilities/src/time/timing' +import { useOnboardingContext } from 'wallet/src/features/onboarding/OnboardingContext' +import { BackupType } from 'wallet/src/features/wallet/accounts/types' +import { validateMnemonic } from 'wallet/src/utils/mnemonics' + +export function PasswordImport({ + flow, + allowBack = true, +}: { + flow: ExtensionOnboardingFlow + allowBack?: boolean +}): JSX.Element { + const { goToNextStep, goToPreviousStep } = useOnboardingSteps() + + const { getOnboardingAccountMnemonicString, generateImportedAccountsByMnemonic } = useOnboardingContext() + const mnemonicString = getOnboardingAccountMnemonicString() + + const onSubmit = useCallback( + async (password: string) => { + const { validMnemonic } = validateMnemonic(mnemonicString) + + if (!validMnemonic) { + throw new Error('Mnemonic are invalid on PasswordImport screen') + } + + goToNextStep() + + // TODO: EXT-1164 - Move Keyring methods to workers to not block main thread during onboarding + // start running the validation after going to next step since they clog the main thread with work + // plus just a bit of extra leeway since animations can take just a tad extra to finish + await sleep(ONBOARDING_PANE_TRANSITION_DURATION_WITH_LEEWAY) + await generateImportedAccountsByMnemonic(validMnemonic, password, BackupType.Manual) + }, + [mnemonicString, goToNextStep, generateImportedAccountsByMnemonic], + ) + + return +} diff --git a/apps/extension/src/app/features/onboarding/PinReminder.tsx b/apps/extension/src/app/features/onboarding/PinReminder.tsx new file mode 100644 index 00000000000..8e54c88d180 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/PinReminder.tsx @@ -0,0 +1,64 @@ +import { useTranslation } from 'react-i18next' +import { Flex, Text } from 'ui/src' +import { Pin, X } from 'ui/src/components/icons' +import { iconSizes, zIndices } from 'ui/src/theme' + +const POPUP_WIDTH = 240 +const POPUP_OFFSET = 4 +const POPUP_SHADOW_RADIUS = 8 + +export function PinReminder({ + onClose, + style = 'popup', +}: { + onClose?: () => void + style?: 'inline' | 'popup' +}): JSX.Element { + const { t } = useTranslation() + + return ( + + + + + {t('onboarding.complete.pin.title')} + + + {t('onboarding.complete.pin.description')} + + + {onClose && ( + + + + )} + + ) +} + +const styles = { + inline: { + position: 'relative' as const, + width: '100%', + }, + popup: { + position: 'absolute' as const, + right: POPUP_OFFSET, + top: POPUP_OFFSET, + width: POPUP_WIDTH, + zIndex: zIndices.popover, + }, +} diff --git a/apps/extension/src/app/features/onboarding/SyncFromPhoneButton.tsx b/apps/extension/src/app/features/onboarding/SyncFromPhoneButton.tsx new file mode 100644 index 00000000000..a3bdb179fb1 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/SyncFromPhoneButton.tsx @@ -0,0 +1,32 @@ +import { useTranslation } from 'react-i18next' +import { OnboardingRoutes, TopLevelRoutes } from 'src/app/navigation/constants' +import { navigate } from 'src/app/navigation/state' +import { Flex, Text, TouchableArea } from 'ui/src' +import { ScanQr } from 'ui/src/components/icons' + +export function SyncFromPhoneButton({ + isResetting, + fill, +}: { + isResetting?: boolean + fill?: boolean +}): JSX.Element | null { + const { t } = useTranslation() + + return ( + + navigate(`/${TopLevelRoutes.Onboarding}/${isResetting ? OnboardingRoutes.ResetScan : OnboardingRoutes.Scan}`) + } + > + + + + {t('onboarding.intro.mobileScan.button')} + + + + ) +} diff --git a/apps/extension/src/app/features/onboarding/Terms.tsx b/apps/extension/src/app/features/onboarding/Terms.tsx new file mode 100644 index 00000000000..67c1eb12749 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/Terms.tsx @@ -0,0 +1,30 @@ +import { PropsWithChildren } from 'react' +import { Trans } from 'react-i18next' +import { Link, LinkProps } from 'react-router-dom' +import { Text } from 'ui/src' +import { uniswapUrls } from 'uniswap/src/constants/urls' + +export function Terms(): JSX.Element { + return ( + + , + highlightPrivacy: , + }} + i18nKey="onboarding.termsOfService" + /> + + ) +} + +function LinkWrapper(props: PropsWithChildren): JSX.Element { + const { children, ...rest } = props + return ( + + + {children} + + + ) +} diff --git a/apps/extension/src/app/features/onboarding/UniconWithLockIcon.tsx b/apps/extension/src/app/features/onboarding/UniconWithLockIcon.tsx new file mode 100644 index 00000000000..b5784b9f26e --- /dev/null +++ b/apps/extension/src/app/features/onboarding/UniconWithLockIcon.tsx @@ -0,0 +1,21 @@ +import { Flex, Unicon } from 'ui/src' +import { FileListLock } from 'ui/src/components/icons' +import { iconSizes } from 'ui/src/theme' + +export function UniconWithLockIcon({ address }: { address: Address }): JSX.Element { + return ( + + + + + + + ) +} diff --git a/apps/extension/src/app/features/onboarding/__snapshots__/KeyboardKey.test.tsx.snap b/apps/extension/src/app/features/onboarding/__snapshots__/KeyboardKey.test.tsx.snap new file mode 100644 index 00000000000..bf552b6a263 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/__snapshots__/KeyboardKey.test.tsx.snap @@ -0,0 +1,70 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`KeyboardKey Component renders correctly with state Highlighted 1`] = ` +
+ + +
+ + Shift + +
+
+
+
+`; + +exports[`KeyboardKey Component renders correctly with state KeyDown 1`] = ` +
+ + +
+ + Shift + +
+
+
+
+`; + +exports[`KeyboardKey Component renders correctly with state KeyUp 1`] = ` +
+ + +
+ + Shift + +
+
+
+
+`; diff --git a/apps/extension/src/app/features/onboarding/alerts/selectors.ts b/apps/extension/src/app/features/onboarding/alerts/selectors.ts new file mode 100644 index 00000000000..69e886629ac --- /dev/null +++ b/apps/extension/src/app/features/onboarding/alerts/selectors.ts @@ -0,0 +1,6 @@ +import { AlertsState } from 'src/app/features/onboarding/alerts/slice' +import { WebState } from 'src/store/webReducer' + +export function selectAlertsState(name: T): (state: WebState) => AlertsState[T] { + return (state) => state.alerts[name] +} diff --git a/apps/extension/src/app/features/onboarding/alerts/slice.ts b/apps/extension/src/app/features/onboarding/alerts/slice.ts new file mode 100644 index 00000000000..7c65df953eb --- /dev/null +++ b/apps/extension/src/app/features/onboarding/alerts/slice.ts @@ -0,0 +1,33 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit' + +export enum AlertName { + PinToToolbar = 'PinToToolbar', +} + +export interface AlertsState { + [AlertName.PinToToolbar]: { + isOpen: boolean + } +} + +const initialState: AlertsState = { + [AlertName.PinToToolbar]: { + isOpen: true, + }, +} + +const slice = createSlice({ + name: 'alerts', + initialState, + reducers: { + openAlert: (state, action: PayloadAction) => { + state[action.payload].isOpen = true + }, + closeAlert: (state, action: PayloadAction) => { + state[action.payload].isOpen = false + }, + }, +}) + +export const { openAlert, closeAlert } = slice.actions +export const { reducer: alertsReducer } = slice diff --git a/apps/extension/src/app/features/onboarding/create/NameWallet.tsx b/apps/extension/src/app/features/onboarding/create/NameWallet.tsx new file mode 100644 index 00000000000..8fd4496fb18 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/create/NameWallet.tsx @@ -0,0 +1,72 @@ +import { useState } from 'react' +import { Input } from 'src/app/components/Input' +import { saveDappConnection } from 'src/app/features/dapp/actions' +import { OnboardingScreen } from 'src/app/features/onboarding/OnboardingScreen' +import { useOnboardingSteps } from 'src/app/features/onboarding/OnboardingSteps' +import { Flex, Text, Unicon } from 'ui/src' +import { fonts, iconSizes } from 'ui/src/theme' +import { UNISWAP_WEB_URL } from 'uniswap/src/constants/urls' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { ExtensionOnboardingFlow, ExtensionOnboardingScreens } from 'uniswap/src/types/screens/extension' +import { shortenAddress } from 'uniswap/src/utils/addresses' +import { useOnboardingContext } from 'wallet/src/features/onboarding/OnboardingContext' + +export function NameWallet(): JSX.Element { + const { getOnboardingAccount, setPendingWalletName } = useOnboardingContext() + const onboardingAccount = getOnboardingAccount() + + const { goToNextStep, goToPreviousStep } = useOnboardingSteps() + const [walletName, setWalletName] = useState('') + + const onboardingAccountAddress = onboardingAccount?.address + + const onSubmit = async (): Promise => { + if (walletName) { + setPendingWalletName(walletName) + } + + if (onboardingAccount) { + await saveDappConnection(UNISWAP_WEB_URL, onboardingAccount) + } + + goToNextStep() + } + + return ( + + : undefined + } + nextButtonEnabled={true} + nextButtonText="Finish" + subtitle="This nickname is only visible to you" + title="Give your wallet a name" + onBack={goToPreviousStep} + onSubmit={onSubmit} + > + + + + {onboardingAccountAddress && shortenAddress(onboardingAccountAddress)} + + + + + ) +} diff --git a/apps/extension/src/app/features/onboarding/create/PasswordCreate.tsx b/apps/extension/src/app/features/onboarding/create/PasswordCreate.tsx new file mode 100644 index 00000000000..3114e7ed10e --- /dev/null +++ b/apps/extension/src/app/features/onboarding/create/PasswordCreate.tsx @@ -0,0 +1,23 @@ +import { ONBOARDING_PANE_TRANSITION_DURATION_WITH_LEEWAY } from 'src/app/features/onboarding/OnboardingPaneAnimatedContents' +import { useOnboardingSteps } from 'src/app/features/onboarding/OnboardingSteps' +import { Password } from 'src/app/features/onboarding/Password' +import { ExtensionOnboardingFlow } from 'uniswap/src/types/screens/extension' +import { sleep } from 'utilities/src/time/timing' +import { useOnboardingContext } from 'wallet/src/features/onboarding/OnboardingContext' + +export function PasswordCreate(): JSX.Element { + const { goToNextStep } = useOnboardingSteps() + const { generateOnboardingAccount } = useOnboardingContext() + + const onComplete = async (password: string): Promise => { + goToNextStep() + + // TODO: EXT-1164 - Move Keyring methods to workers to not block main thread during onboarding + // start running the validation after going to next step since they clog the main thread with work + // plus just a bit of extra leeway since animations can take just a tad extra to finish + await sleep(ONBOARDING_PANE_TRANSITION_DURATION_WITH_LEEWAY) + await generateOnboardingAccount(password) + } + + return +} diff --git a/apps/extension/src/app/features/onboarding/create/TestMnemonic.tsx b/apps/extension/src/app/features/onboarding/create/TestMnemonic.tsx new file mode 100644 index 00000000000..3762ba728a6 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/create/TestMnemonic.tsx @@ -0,0 +1,261 @@ +import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { TextInput } from 'react-native' +import { Input } from 'src/app/components/Input' +import { OnboardingScreen } from 'src/app/features/onboarding/OnboardingScreen' +import { useOnboardingSteps } from 'src/app/features/onboarding/OnboardingSteps' +import { Flex, Square, Text } from 'ui/src' +import { Check, FileListCheck } from 'ui/src/components/icons' +import { iconSizes, zIndices } from 'ui/src/theme' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { ExtensionOnboardingFlow, ExtensionOnboardingScreens } from 'uniswap/src/types/screens/extension' +import { useDebounce } from 'utilities/src/time/timing' +import { useOnboardingContext } from 'wallet/src/features/onboarding/OnboardingContext' +import { PASSWORD_VALIDATION_DEBOUNCE_MS } from 'wallet/src/utils/password' + +export function TestMnemonic({ numberOfTests = 3 }: { numberOfTests?: number }): JSX.Element { + const { t } = useTranslation() + + const { getOnboardingAccountAddress, getOnboardingAccountMnemonic } = useOnboardingContext() + const onboardingAccountAddress = getOnboardingAccountAddress() + const onboardingAccountMnemonic = getOnboardingAccountMnemonic() + + const { goToNextStep, goToPreviousStep } = useOnboardingSteps() + + const [completedTests, markTestCompleted] = useReducer((v: number) => v + 1, 0) + const [userWordInput, setUserWordInput] = useState('') + const [hasError, setHasError] = useState(false) + + const isLastTest = completedTests === numberOfTests - 1 + + // Pick NUMBER_OF_TESTS random words + const testingWordIndexes = useMemo( + () => + onboardingAccountMnemonic ? selectRandomNumbers(onboardingAccountMnemonic.length, numberOfTests) : undefined, + [onboardingAccountMnemonic, numberOfTests], + ) + + // Save the next word index for reuse, ensuring it's not undefined + const nextWordIndex = useMemo(() => testingWordIndexes?.[completedTests] ?? 0, [completedTests, testingWordIndexes]) + const nextWordNumber = nextWordIndex + 1 + const validWord = userWordInput === onboardingAccountMnemonic?.[nextWordIndex] + const isComplete = validWord && isLastTest + + useEffect(() => { + if (validWord) { + setTimeout(() => { + if (!isLastTest) { + markTestCompleted() + setUserWordInput('') + } else { + goToNextStep() + } + }, 200) + } + }, [validWord, goToNextStep, isLastTest]) + + const debouncedWord = useDebounce(userWordInput, PASSWORD_VALIDATION_DEBOUNCE_MS) + useEffect(() => { + setHasError(!!debouncedWord && debouncedWord !== onboardingAccountMnemonic?.[nextWordIndex]) + }, [debouncedWord, onboardingAccountMnemonic, nextWordIndex]) + + const onNext = useCallback((): void => { + if (!onboardingAccountMnemonic || !onboardingAccountAddress) { + return + } + + goToNextStep() + }, [onboardingAccountMnemonic, goToNextStep, onboardingAccountAddress]) + + return ( + + + + + } + nextButtonEnabled={false} + nextButtonText={t('onboarding.backup.manual.progress', { + completedStepsCount: isComplete ? numberOfTests : completedTests, + totalStepsCount: numberOfTests, + })} + nextButtonTheme="secondary" + subtitle={t('onboarding.backup.manual.subtitle', { count: nextWordNumber, ordinal: true })} + title={t('onboarding.backup.manual.title')} + onBack={goToPreviousStep} + onSkip={onNext} + onSubmit={onNext} + > + + { + setUserWordInput(value) + if (hasError) { + setHasError(false) + } + }} + /> + + {t('onboarding.backup.manual.error')} + + + + + ) +} + +type InputStackBaseProps = { + value?: string + onChangeText: (word: string) => void +} + +function RecoveryPhraseInputStack({ + nextWordNumber, + numInputsBelow, + numTotalSteps, + isInputValid, + value, + onChangeText, +}: InputStackBaseProps & { + numInputsBelow: number + numTotalSteps: number + nextWordNumber: number + isInputValid: boolean +}): JSX.Element { + return ( + + + {isInputValid ? ( + + + + ) : null} + + + + ) +} + +type InputStackProps = InputStackBaseProps & { + total: number + current: number + prefixText: string +} + +export function InputStack({ onChangeText, total, value, current, prefixText }: InputStackProps): JSX.Element { + const { t } = useTranslation() + const refs = useRef([]) + const prefixTexts = useRef([]) + + // this is weird because we only get the new word as it renders + // but avoiding a bit of a refactor before beta release, should be safe: + prefixTexts.current[current] ||= prefixText + + useEffect(() => { + // Wait until the next tick to focus the input, otherwise the state update interferes with the focus event. + setTimeout(() => { + refs.current?.[current]?.focus() + }, 1) + }, [current]) + + return ( + + {new Array(total).fill(0).map((_, i) => { + const isHidden = i < current + const isCurrentlyActive = i === current + const isBelow = i > current + const belowOffset = i - current + + return ( + + + {prefixTexts.current[i] || ''} + + { + if (inputNode) { + refs.current[i] = inputNode + } + }} + centered + large + borderColor="$surface3" + borderRadius="$rounded20" + flex={1} + placeholder={t('onboarding.backup.manual.placeholder')} + shadowColor="$shadowColor" + shadowOffset={{ width: 0, height: 4 }} + shadowOpacity={0.4} + shadowRadius={10} + value={value} + zIndex={zIndices.sticky} + onChangeText={onChangeText} + /> + + ) + })} + + ) +} + +function selectRandomNumbers(maxNumber: number, numberOfNumbers: number): number[] { + const shuffledIndexes = [...Array(maxNumber).keys()].sort(() => 0.5 - Math.random()) + const selectedIndexes = shuffledIndexes.slice(0, numberOfNumbers) + selectedIndexes.sort((a, b) => a - b) + return selectedIndexes +} diff --git a/apps/extension/src/app/features/onboarding/create/ViewMnemonic.tsx b/apps/extension/src/app/features/onboarding/create/ViewMnemonic.tsx new file mode 100644 index 00000000000..dc1a48af1a8 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/create/ViewMnemonic.tsx @@ -0,0 +1,148 @@ +import { FunctionComponent, useEffect, useState } from 'react' +import { Trans, useTranslation } from 'react-i18next' +import { MnemonicViewer } from 'src/app/components/MnemonicViewer' +import { OnboardingScreen } from 'src/app/features/onboarding/OnboardingScreen' +import { useOnboardingSteps } from 'src/app/features/onboarding/OnboardingSteps' +import { TopLevelRoutes } from 'src/app/navigation/constants' +import { navigate } from 'src/app/navigation/state' +import { CheckBox, Circle, Flex, IconProps, Square, Text } from 'ui/src' +import { AlertTriangle, EyeOff, FileListLock, Key, PencilDetailed } from 'ui/src/components/icons' +import { iconSizes } from 'ui/src/theme' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { ExtensionOnboardingFlow, ExtensionOnboardingScreens } from 'uniswap/src/types/screens/extension' +import { logger } from 'utilities/src/logger/logger' +import { useOnboardingContext } from 'wallet/src/features/onboarding/OnboardingContext' + +enum ViewStep { + Info, + View, +} + +export function ViewMnemonic(): JSX.Element { + const { t } = useTranslation() + + const [viewStep, setViewStep] = useState(ViewStep.Info) + + const { goToNextStep } = useOnboardingSteps() + + const [disclaimerChecked, setDisclaimerChecked] = useState(false) + + const { getOnboardingAccountAddress, getOnboardingAccountMnemonic, retrieveOnboardingAccountMnemonic } = + useOnboardingContext() + const onboardingAccountAddress = getOnboardingAccountAddress() + const onboardingAccountMnemonic = getOnboardingAccountMnemonic() + + useEffect(() => { + if (!onboardingAccountMnemonic) { + retrieveOnboardingAccountMnemonic().catch((e) => { + logger.error(e, { + tags: { file: 'ViewMnemonic', function: 'retrieveOnboardingAccountMnemonic' }, + }) + }) + } + }, [onboardingAccountMnemonic, retrieveOnboardingAccountMnemonic]) + + const onSubmit = (): void => { + if (viewStep === ViewStep.Info) { + setViewStep(ViewStep.View) + return + } + + if (onboardingAccountAddress && disclaimerChecked) { + goToNextStep() + } + } + + // On view step, next button should be enabled if mnemonic has been created. + // On disclaimer step, next button should be enabled if disclaimer is checked and mnemonic has been created. + const shouldEnableNextButton = + viewStep === ViewStep.View ? !!onboardingAccountAddress && disclaimerChecked : !!onboardingAccountAddress + + return ( + + + {viewStep === ViewStep.View ? ( + + ) : ( + + )} + + } + nextButtonEnabled={shouldEnableNextButton} + nextButtonText={t('common.button.continue')} + subtitle={ + viewStep === ViewStep.View + ? t('onboarding.backup.view.subtitle.message2') + : t('onboarding.backup.view.subtitle.message1') + } + title={t('onboarding.backup.view.title')} + onBack={(): void => + navigate(`/${TopLevelRoutes.Onboarding}`, { + replace: true, + }) + } + onSubmit={onSubmit} + > + {viewStep === ViewStep.Info ? ( + + + + {t('onboarding.backup.view.warning.message1')} + + + + {t('onboarding.backup.view.warning.message2')} + + + + + }} + i18nKey="onboarding.backup.view.warning.message3" + /> + + + + ) : ( + + + + {t('onboarding.backup.view.disclaimer')}} + onCheckPressed={(currentValue: boolean): void => setDisclaimerChecked(!currentValue)} + /> + + + )} + + + ) +} + +function WarningIcon({ Icon }: { Icon: FunctionComponent }): JSX.Element { + return ( + + + + ) +} diff --git a/apps/extension/src/app/features/onboarding/import/ImportMnemonic.tsx b/apps/extension/src/app/features/onboarding/import/ImportMnemonic.tsx new file mode 100644 index 00000000000..05814e7aec7 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/import/ImportMnemonic.tsx @@ -0,0 +1,336 @@ +import { wordlists } from 'ethers' +import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { + NativeSyntheticEvent, + TextInputChangeEventData, + TextInputFocusEventData, + TextInputKeyPressEventData, +} from 'react-native' +import { OnboardingScreen } from 'src/app/features/onboarding/OnboardingScreen' +import { useOnboardingSteps } from 'src/app/features/onboarding/OnboardingSteps' +import { SyncFromPhoneButton } from 'src/app/features/onboarding/SyncFromPhoneButton' +import { TopLevelRoutes } from 'src/app/navigation/constants' +import { navigate } from 'src/app/navigation/state' +import { useAppDispatch } from 'src/store/store' +import { Button, Flex, FlexProps, Input, Square, Text, inputStyles } from 'ui/src' +import { FileListLock, RotatableChevron } from 'ui/src/components/icons' +import { fonts, iconSizes } from 'ui/src/theme' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { ExtensionOnboardingFlow, ExtensionOnboardingScreens } from 'uniswap/src/types/screens/extension' +import { useDebounce } from 'utilities/src/time/timing' +import { useOnboardingContext } from 'wallet/src/features/onboarding/OnboardingContext' +import { EditAccountAction, editAccountActions } from 'wallet/src/features/wallet/accounts/editAccountSaga' +import { useSignerAccounts } from 'wallet/src/features/wallet/hooks' +import { isValidMnemonicWord, validateMnemonic } from 'wallet/src/utils/mnemonics' + +export function ImportMnemonic(): JSX.Element { + const { t } = useTranslation() + const dispatch = useAppDispatch() + const [mnemonic, setMnemonic] = useState(new Array(24).fill('')) + const { addOnboardingAccountMnemonic } = useOnboardingContext() + const [expanded, setExpanded] = useState(false) + const [errors, setErrors] = useState>({}) + const isEmptyMnemonic = useMemo(() => !mnemonic.join(' ').toLocaleLowerCase().trim(), [mnemonic]) + + const inputRefs = useRef>(Array(24).fill(null)) + + const accounts = useSignerAccounts() + + const { isResetting, goToNextStep } = useOnboardingSteps() + + useEffect(() => { + const handlePaste = (event: ClipboardEvent): void | (() => void) => { + if (!event.clipboardData) { + return + } + const pastedText = event.clipboardData.getData('text').toLowerCase().trim() + if (!pastedText) { + return + } + const { validMnemonic, error } = validateMnemonic(pastedText) + if (error || !validMnemonic) { + return + } + // We conditionally prevent default here because we want paste to work as expected in all other cases. + event.preventDefault() + const words = validMnemonic.replaceAll(/\s+/g, ' ').split(' ') + setExpanded(words.length > 12) + + const newMnemonic = Array(24) + .fill('') + .map((_, i) => words[i] || '') + + setMnemonic(newMnemonic) + setErrors({}) + + // We focus the last input on the next tick after the state has been updated. + setTimeout(() => inputRefs.current[words.length - 1]?.focus(), 0) + + // Clear clipboard after paste + navigator.clipboard.writeText('').catch(() => {}) + } + + window.document.addEventListener('paste', handlePaste) + + return () => { + window.document.removeEventListener('paste', handlePaste) + } + }, [setMnemonic]) + + const handleChange = useCallback( + (index: number) => + (event: NativeSyntheticEvent): void => { + const newMnemonic = [...mnemonic] + const word = event.nativeEvent.text + + // Focus next input when the space key is pressed. + if (word.length > 1 && word.endsWith(' ')) { + inputRefs.current[index + 1]?.focus() + } + + newMnemonic[index] = word.trim() + setMnemonic(newMnemonic) + }, + [mnemonic, setMnemonic], + ) + + const handleKeyPress = useCallback( + (index: number) => + (event: NativeSyntheticEvent): void => { + // Focus previous input when the backspace key is pressed. + if (event.nativeEvent.key === 'Backspace' && !mnemonic[index] && index > 0) { + inputRefs.current[index - 1]?.focus() + } + }, + [mnemonic], + ) + + const handleBlur = useCallback( + (index: number) => + (event: NativeSyntheticEvent): void => { + const word = event.nativeEvent.text + + if (!word && errors[index] !== undefined) { + setErrors({ ...errors, [index]: undefined }) + } + if (!word) { + return + } + const wordInList = wordlists.en?.getWordIndex(word) !== -1 + setErrors({ ...errors, [index]: !wordInList }) + }, + [errors], + ) + + const onSubmit = useCallback(async () => { + if (isEmptyMnemonic) { + return + } + + if (isResetting) { + // Remove all accounts before importing mnemonic. + await dispatch( + editAccountActions.trigger({ + type: EditAccountAction.Remove, + accounts, + }), + ) + } + + addOnboardingAccountMnemonic(mnemonic) + goToNextStep() + }, [accounts, dispatch, goToNextStep, isResetting, mnemonic, addOnboardingAccountMnemonic, isEmptyMnemonic]) + + const debouncedMnemonic = useDebounce(mnemonic, 500) + + const { error: mnemonicValidationError, invalidWordCount } = useMemo(() => { + const mnemonicString = debouncedMnemonic.join(' ').toLowerCase() + + if (!mnemonicString.trim()) { + return { error: undefined, invalidWordCount: undefined } + } + + return validateMnemonic(mnemonicString) + }, [debouncedMnemonic]) + + const errorMessageToDisplay = useMemo(() => { + // If all cells are filled, but there is an error, display the invalid phrase error + const trimmedMnemonic = expanded ? mnemonic : mnemonic.slice(0, 12) + const allCellsFilled = trimmedMnemonic.every((word) => word.length > 0) + + if (allCellsFilled && mnemonicValidationError) { + return t('onboarding.importMnemonic.error.invalidPhrase') + } + + if (mnemonicValidationError && invalidWordCount && invalidWordCount >= 1) { + return t('onboarding.import.error.invalidWords', { count: invalidWordCount }) + } + + return undefined + }, [expanded, mnemonic, mnemonicValidationError, t, invalidWordCount]) + + return ( + + + + + + } + belowFrameContent={ + isResetting ? ( + + + + ) : undefined + } + nextButtonEnabled={!isEmptyMnemonic && !mnemonicValidationError && !errorMessageToDisplay} + nextButtonText={t('common.button.continue')} + subtitle={t('onboarding.importMnemonic.subtitle')} + title={t('onboarding.importMnemonic.title')} + onBack={isResetting ? undefined : (): void => navigate(`/${TopLevelRoutes.Onboarding}`, { replace: true })} + onSubmit={onSubmit} + > + <> + + {errorMessageToDisplay ?? DUMMY_TEXT} {/* To prevent layout shift */} + + + + {mnemonic.map( + (word, index) => + Boolean(expanded || (!expanded && index < 12)) && ( + + (inputRefs.current[index] = ref)} + handleBlur={handleBlur} + handleChange={handleChange} + handleKeyPress={handleKeyPress} + index={index} + word={word} + /> + + ), + )} + + + + + + + + ) +} + +const RecoveryPhraseWord = forwardRef< + Input, + { + word: string + index: number + handleBlur: (index: number) => (event: NativeSyntheticEvent) => void + handleChange: (index: number) => (event: NativeSyntheticEvent) => void + handleKeyPress: (index: number) => (e: NativeSyntheticEvent) => void + } +>(function _RecoveryPhraseWord({ word, index, handleBlur, handleChange, handleKeyPress }, ref): JSX.Element { + const debouncedWord = useDebounce(word, 500) + const showError = isValidMnemonicWord(debouncedWord) + + return ( + + + {(index + 1).toString()} + + + + + ) +}) + +const styles = { + inputFocus: { + backgroundColor: '$surface1', + borderWidth: 1, + borderColor: '$surface3', + outlineWidth: 0, + }, + recoveryPhraseWord: { + width: 'calc(calc(100% - 32px) / 3)', // 3 columns with 16px gap + }, +} as const + +const DUMMY_TEXT = 'DUMMY TEXT' diff --git a/apps/extension/src/app/features/onboarding/import/SelectWallets.tsx b/apps/extension/src/app/features/onboarding/import/SelectWallets.tsx new file mode 100644 index 00000000000..d0c1ae89847 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/import/SelectWallets.tsx @@ -0,0 +1,208 @@ +import { useApolloClient } from '@apollo/client' +import { useCallback, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { SelectWalletsSkeleton } from 'src/app/components/loading/SelectWalletSkeleton' +import { saveDappConnection } from 'src/app/features/dapp/actions' +import { OnboardingScreen } from 'src/app/features/onboarding/OnboardingScreen' +import { useOnboardingSteps } from 'src/app/features/onboarding/OnboardingSteps' +import { Flex, ScrollView, Square, Text } from 'ui/src' +import { WalletFilled } from 'ui/src/components/icons' +import { iconSizes } from 'ui/src/theme' +import { UNISWAP_WEB_URL } from 'uniswap/src/constants/urls' +import { + SelectWalletScreenDocument, + SelectWalletScreenQuery, +} from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' +import { FeatureFlags } from 'uniswap/src/features/gating/flags' +import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { ExtensionOnboardingFlow, ExtensionOnboardingScreens } from 'uniswap/src/types/screens/extension' +import { useAsyncData } from 'utilities/src/react/hooks' +import { ONE_SECOND_MS } from 'utilities/src/time/time' +import { useTimeout } from 'utilities/src/time/timing' +import WalletPreviewCard from 'wallet/src/components/WalletPreviewCard/WalletPreviewCard' +import { useOnboardingContext } from 'wallet/src/features/onboarding/OnboardingContext' +import { NUMBER_OF_WALLETS_TO_IMPORT } from 'wallet/src/features/onboarding/createImportedAccounts' +import { useSelectAccounts } from 'wallet/src/features/onboarding/hooks/useSelectAccounts' +import { fetchUnitagByAddresses } from 'wallet/src/features/unitags/api' + +const FORCED_LOADING_DURATION = 3 * ONE_SECOND_MS // 3s + +interface ImportableAccount { + ownerAddress: string + balance: number | undefined +} + +function isImportableAccount(account: { + ownerAddress: string | undefined + balance: Maybe +}): account is ImportableAccount { + return (account as ImportableAccount).ownerAddress !== undefined +} + +export function SelectWallets({ flow }: { flow: ExtensionOnboardingFlow }): JSX.Element { + const { t } = useTranslation() + const shouldAutoConnect = useFeatureFlag(FeatureFlags.ExtensionAutoConnect) + + const { goToNextStep, goToPreviousStep } = useOnboardingSteps() + + const { getImportedAccountsAddresses, selectImportedAccounts } = useOnboardingContext() + const importedAccountsAddresses = getImportedAccountsAddresses() + + const isImportedAccountsReady = importedAccountsAddresses?.length === NUMBER_OF_WALLETS_TO_IMPORT + + const { + data: initialShownAccounts, + isLoading: loading, + error, + refetch, + } = useImportableAccounts(isImportedAccountsReady ? importedAccountsAddresses : undefined) + + const onRetry = useCallback(async () => { + setIsForcedLoading(true) + refetch() + }, [refetch]) + + const showError = error && !initialShownAccounts?.length + + const { selectedAddresses, toggleAddressSelection } = useSelectAccounts(initialShownAccounts) + + const onSubmit = useCallback(async () => { + const importedAccounts = await selectImportedAccounts(selectedAddresses) + + // TODO(EXT-1375): figure out how to better auto connect existing wallets that may have connected via WC or some other method. + // Once that's solved the feature flag can be turned on/removed. + if (shouldAutoConnect && importedAccounts[0]) { + await saveDappConnection(UNISWAP_WEB_URL, importedAccounts[0]) + } + + goToNextStep() + }, [selectImportedAccounts, selectedAddresses, goToNextStep, shouldAutoConnect]) + + // Force a fixed duration loading state for smoother transition (as we show different UI for 1 vs multiple wallets) + const [isForcedLoading, setIsForcedLoading] = useState(true) + useTimeout(() => setIsForcedLoading(false), FORCED_LOADING_DURATION) + + const isLoading = loading || isForcedLoading || !isImportedAccountsReady + + const title = showError ? t('onboarding.selectWallets.title.error') : t('onboarding.selectWallets.title.default') + + return ( + + + + + } + nextButtonEnabled={showError || (isImportedAccountsReady && selectedAddresses.length > 0 && !isLoading)} + nextButtonText={showError ? t('common.button.retry') : t('common.button.continue')} + nextButtonTheme={showError ? 'secondary' : 'primary'} + title={title} + onBack={goToPreviousStep} + onSubmit={showError ? onRetry : onSubmit} + > + + + {showError ? ( + + {t('onboarding.selectWallets.error')} + + ) : isLoading ? ( + + + + ) : ( + initialShownAccounts?.map((account) => { + const { ownerAddress, balance } = account + return ( + + ) + }) + )} + + + + + ) +} + +function useImportableAccounts(addresses?: string[]): { + isLoading: boolean + data?: ImportableAccount[] + error?: Error + refetch: () => void +} { + const [refetchCount, setRefetchCount] = useState(0) + const apolloClient = useApolloClient() + + const refetch = useCallback(() => setRefetchCount((count) => count + 1), []) + + const fetch = useCallback(async (): Promise => { + if (!addresses) { + return + } + + const fetchBalances = apolloClient.query({ + query: SelectWalletScreenDocument, + variables: { ownerAddresses: addresses }, + }) + + const fetchUnitags = fetchUnitagByAddresses(addresses) + + const [balancesResponse, unitagsResponse] = await Promise.all([fetchBalances, fetchUnitags]) + + const unitagsByAddress = unitagsResponse?.data + + const allAddressBalances = balancesResponse.data.portfolios + + const importableAccounts = allAddressBalances + ?.map((address) => ({ + ownerAddress: address?.ownerAddress, + balance: address?.tokensTotalDenominatedValue?.value, + })) + .filter(isImportableAccount) + + const accountsWithBalanceOrUnitag: ImportableAccount[] | undefined = importableAccounts?.filter((address) => { + const hasBalance = Boolean(address.balance && address.balance > 0) + const hasUnitag = unitagsByAddress?.[address.ownerAddress] !== undefined + return hasBalance || hasUnitag + }) + + if (accountsWithBalanceOrUnitag?.length) { + return accountsWithBalanceOrUnitag + } + + // If all addresses have 0 total token value and no unitags are associated with any of them, show the first address. + const firstImportableAccount: ImportableAccount | undefined = importableAccounts?.[0] + if (firstImportableAccount) { + return [firstImportableAccount] + } + + // If query for address balances returned no results, show the first address. + const firstPendingAddress = addresses[0] + if (firstPendingAddress) { + return [{ ownerAddress: firstPendingAddress, balance: undefined }] + } + + throw new Error('No importable accounts found') + // We use `refetchCount` as a dependency to manually trigger a refetch when calling the `refetch` function. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [addresses, apolloClient, refetchCount]) + + const response = useAsyncData(fetch) + + return useMemo( + () => ({ + ...response, + refetch, + }), + [refetch, response], + ) +} diff --git a/apps/extension/src/app/features/onboarding/intro/GetOnTheBetaWaitlistBanner.tsx b/apps/extension/src/app/features/onboarding/intro/GetOnTheBetaWaitlistBanner.tsx new file mode 100644 index 00000000000..ce5fee91068 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/intro/GetOnTheBetaWaitlistBanner.tsx @@ -0,0 +1,43 @@ +import { useTranslation } from 'react-i18next' +import { Button, Flex, FlexProps, Image, Text, useIsDarkMode } from 'ui/src' +import { APP_SCREENSHOT_DARK, APP_SCREENSHOT_LIGHT } from 'ui/src/assets' +import { RotatableChevron } from 'ui/src/components/icons' + +export function GetOnTheBetaWaitlistBanner(): JSX.Element { + const { t } = useTranslation() + const isDarkMode = useIsDarkMode() + + return ( + + ) +} diff --git a/apps/extension/src/app/features/onboarding/intro/IntroScreen.tsx b/apps/extension/src/app/features/onboarding/intro/IntroScreen.tsx new file mode 100644 index 00000000000..8e795dbf317 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/intro/IntroScreen.tsx @@ -0,0 +1,72 @@ +import { useTranslation } from 'react-i18next' +import { Complete } from 'src/app/features/onboarding/Complete' +import { SyncFromPhoneButton } from 'src/app/features/onboarding/SyncFromPhoneButton' +import { Terms } from 'src/app/features/onboarding/Terms' +import { MainIntroWrapper } from 'src/app/features/onboarding/intro/MainIntroWrapper' +import { OnboardingRoutes, TopLevelRoutes } from 'src/app/navigation/constants' +import { navigate } from 'src/app/navigation/state' +import { checksIfSupportsSidePanel } from 'src/app/utils/chrome' +import { isOnboardedSelector } from 'src/app/utils/isOnboardedSelector' +import { useAppSelector } from 'src/store/store' +import { Button, Flex, Text } from 'ui/src' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { ExtensionOnboardingScreens } from 'uniswap/src/types/screens/extension' +import { useTimeout } from 'utilities/src/time/timing' + +export function IntroScreen(): JSX.Element { + const { t } = useTranslation() + + const isOnboarded = useAppSelector(isOnboardedSelector) + + // Detections for some unsupported browsers may not work until stylesheet is loaded + useTimeout(() => { + if (!checksIfSupportsSidePanel()) { + navigate(`/${TopLevelRoutes.Onboarding}/${OnboardingRoutes.UnsupportedBrowser}`) + } + }, 0) + + if (isOnboarded) { + return + } + + return ( + + + + + + } + > + + + + + + + + + + {t('onboarding.intro.mobileScan.title')} + + + + + + + + ) +} diff --git a/apps/extension/src/app/features/onboarding/intro/IntroScreenBetaWaitlist.tsx b/apps/extension/src/app/features/onboarding/intro/IntroScreenBetaWaitlist.tsx new file mode 100644 index 00000000000..d6bbb23be7b --- /dev/null +++ b/apps/extension/src/app/features/onboarding/intro/IntroScreenBetaWaitlist.tsx @@ -0,0 +1,246 @@ +import { useCallback, useEffect, useReducer, useState } from 'react' +import { Trans, useTranslation } from 'react-i18next' +import { Complete } from 'src/app/features/onboarding/Complete' +import { GetOnTheBetaWaitlistBanner } from 'src/app/features/onboarding/intro/GetOnTheBetaWaitlistBanner' +import { MainContentWrapper } from 'src/app/features/onboarding/intro/MainContentWrapper' +import { OnboardingRoutes, TopLevelRoutes } from 'src/app/navigation/constants' +import { navigate } from 'src/app/navigation/state' +import { checksIfSupportsSidePanel } from 'src/app/utils/chrome' +import { isOnboardedSelector } from 'src/app/utils/isOnboardedSelector' +import { UNISWAP_BETA_LOGO } from 'src/assets' +import { useAppSelector } from 'src/store/store' +import { Button, Flex, Image, Input, SpinningLoader, Text, useSporeColors } from 'ui/src' +import { ApproveFilled, FileListLock, Unitag } from 'ui/src/components/icons' +import { iconSizes } from 'ui/src/theme' +import { UnitagWaitlistPositionResponse } from 'uniswap/src/features/unitags/types' +import { shortenAddress } from 'uniswap/src/utils/addresses' +import { useTimeout } from 'utilities/src/time/timing' +import { fetchExtensionWaitlistEligibity } from 'wallet/src/features/unitags/api' + +const UNISWAP_BETA_LOGO_SIZE = 68 + +export function IntroScreenBetaWaitlist(): JSX.Element { + const { t, i18n } = useTranslation() + const colors = useSporeColors() + + const [username, setUsername] = useState('') + const [eligibility, setEligibility] = useState() + const [checkingEligibility, setCheckingEligibility] = useState(false) + + const [_, forceUpdate] = useReducer((x: number): number => x + 1, 0) + + useEffect(() => { + // Initial language change not lead to a rerender for onboarding app + forceUpdate() + }, [i18n.language]) + + // Detections for some unsupported browsers may not work until stylesheet is loaded + useTimeout(() => { + if (!checksIfSupportsSidePanel()) { + navigate(`/${TopLevelRoutes.Onboarding}/${OnboardingRoutes.UnsupportedBrowser}`) + } + }, 0) + + const isSubmitDisabled = (eligibility && !eligibility.isAccepted) || checkingEligibility || !username + + const onCheckEligibility = async (): Promise => { + if (isSubmitDisabled) { + return + } + + setCheckingEligibility(true) + + const { data } = await fetchExtensionWaitlistEligibity(username) + + setCheckingEligibility(false) + setEligibility(data) + } + + const onChangeText = (text: string): void => { + setUsername(text.trim()) + setEligibility(undefined) + } + + const isOnboarded = useAppSelector(isOnboardedSelector) + + if (isOnboarded) { + return + } + + if (eligibility && eligibility.isAccepted) { + return + } + + return ( + + + + + + + + + Uniswap Wallet + + + + BETA + + + + + }} + i18nKey="onboarding.introBetaWaitlist.checkEligibilityInstructions" + t={t} + /> + + + + + + + + + .uni.eth + + + + {eligibility && !eligibility.isAccepted && ( + + + + + + + + {t('onboarding.introBetaWaitlist.ineligibleExplanation')} + + + + )} + + + + + + + ) +} + +function EligibleUnitag({ address, username }: { address: string; username: string }): JSX.Element { + const { t } = useTranslation() + const colors = useSporeColors() + + const onContinue = useCallback(() => { + navigate(`/${TopLevelRoutes.Onboarding}/${OnboardingRoutes.Scan}`) + }, []) + + useEffect(() => { + const handleKeyPress = (event: KeyboardEvent): void => { + if (event.key === 'Enter') { + onContinue() + } + } + + window.addEventListener('keydown', handleKeyPress) + + return () => { + window.removeEventListener('keydown', handleKeyPress) + } + }, [onContinue]) + + return ( + + + + + + + + + + + {t('onboarding.introBetaWaitlist.eligible.title')} + + + + + {t('onboarding.introBetaWaitlist.eligible.tagline')} + + + + + + {username} + + + + + + + + + {shortenAddress(address)} + + + + + + + ) +} diff --git a/apps/extension/src/app/features/onboarding/intro/MainContentWrapper.tsx b/apps/extension/src/app/features/onboarding/intro/MainContentWrapper.tsx new file mode 100644 index 00000000000..e6109e1f6b3 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/intro/MainContentWrapper.tsx @@ -0,0 +1,23 @@ +import { PropsWithChildren } from 'react' +import { ONBOARDING_CONTENT_WIDTH } from 'src/app/features/onboarding/utils' +import { Flex } from 'ui/src' + +export function MainContentWrapper({ children }: PropsWithChildren): JSX.Element { + return ( + + {children} + + ) +} diff --git a/apps/extension/src/app/features/onboarding/intro/MainIntroWrapper.tsx b/apps/extension/src/app/features/onboarding/intro/MainIntroWrapper.tsx new file mode 100644 index 00000000000..c76615a7a06 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/intro/MainIntroWrapper.tsx @@ -0,0 +1,42 @@ +import { PropsWithChildren, ReactNode } from 'react' +import { ONBOARDING_CONTENT_WIDTH } from 'src/app/features/onboarding/utils' +import { Flex } from 'ui/src' +import { LandingBackground } from 'wallet/src/components/landing/LandingBackground' + +// Fixed padding value to align content with a certain point on the background +const CONTAINER_PADDING_TOP = 340 +const LANDING_BACKGROUND_SIZE = 400 + +export function MainIntroWrapper({ + children, + belowFrameContent, +}: PropsWithChildren<{ belowFrameContent?: ReactNode }>): JSX.Element { + return ( + + + + + + + + + {children} + + + {belowFrameContent && ( + + {belowFrameContent} + + )} + + ) +} diff --git a/apps/extension/src/app/features/onboarding/intro/UnsupportedBrowserScreen.tsx b/apps/extension/src/app/features/onboarding/intro/UnsupportedBrowserScreen.tsx new file mode 100644 index 00000000000..9336a2764a5 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/intro/UnsupportedBrowserScreen.tsx @@ -0,0 +1,41 @@ +import { useTranslation } from 'react-i18next' +import { MainIntroWrapper } from 'src/app/features/onboarding/intro/MainIntroWrapper' +import { Flex, Text } from 'ui/src' +import { AlertTriangle } from 'ui/src/components/icons' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { ExtensionScreens } from 'uniswap/src/types/screens/extension' + +export function UnsupportedBrowserScreen(): JSX.Element { + const { t } = useTranslation() + + return ( + + + + + + + + + + + {t('onboarding.extension.unsupported.title')} + + + {t('onboarding.extension.unsupported.description')} + + + + + + + + ) +} diff --git a/apps/extension/src/app/features/onboarding/reset/ResetComplete.tsx b/apps/extension/src/app/features/onboarding/reset/ResetComplete.tsx new file mode 100644 index 00000000000..0e64b2b9976 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/reset/ResetComplete.tsx @@ -0,0 +1,34 @@ +import { useTranslation } from 'react-i18next' +import { terminateStoreSynchronization } from 'src/store/storeSynchronization' +import { Flex, Text } from 'ui/src' +import { Check, GraduationCap } from 'ui/src/components/icons' +import { useFinishOnboarding } from 'wallet/src/features/onboarding/OnboardingContext' + +export function ResetComplete(): JSX.Element { + const { t } = useTranslation() + + // Activates onboarding accounts on component mount + useFinishOnboarding(terminateStoreSynchronization) + + return ( + <> + + + + + + {t('onboarding.resetPassword.complete.title')} + + {t('onboarding.resetPassword.complete.subtitle')} + + + + + + {t('onboarding.resetPassword.complete.safety')} + + + + + ) +} diff --git a/apps/extension/src/app/features/onboarding/scan/OTPInput.tsx b/apps/extension/src/app/features/onboarding/scan/OTPInput.tsx new file mode 100644 index 00000000000..f835447c708 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/scan/OTPInput.tsx @@ -0,0 +1,232 @@ +import { createRef, RefObject, useCallback, useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { NativeSyntheticEvent, TextInput, TextInputChangeEventData, TextInputKeyPressEventData } from 'react-native' +import { OnboardingScreen } from 'src/app/features/onboarding/OnboardingScreen' +import { useOnboardingSteps } from 'src/app/features/onboarding/OnboardingSteps' +import { useScantasticContext } from 'src/app/features/onboarding/scan/ScantasticContextProvider' +import { decryptMessage } from 'src/app/features/onboarding/scan/utils' +import { Flex, Input, inputStyles, Square, Text } from 'ui/src' +import { Mobile } from 'ui/src/components/icons' +import { fonts, iconSizes } from 'ui/src/theme' +import { uniswapUrls } from 'uniswap/src/constants/urls' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { ExtensionOnboardingFlow, ExtensionOnboardingScreens } from 'uniswap/src/types/screens/extension' +import { logger } from 'utilities/src/logger/logger' +import { arraysAreEqual } from 'utilities/src/primitives/array' +import { ONE_SECOND_MS } from 'utilities/src/time/time' +import { useInterval, useTimeout } from 'utilities/src/time/timing' +import { useOnboardingContext } from 'wallet/src/features/onboarding/OnboardingContext' +import { getOtpDurationString } from 'wallet/src/utils/duration' + +const MAX_FAILED_OTP_ATTEMPTS = 3 + +type CharacterSequence = [string, string, string, string, string, string] +const INITIAL_CHARACTER_SEQUENCE: CharacterSequence = ['', '', '', '', '', ''] + +export function OTPInput(): JSX.Element { + const { t } = useTranslation() + const { goToNextStep, goToPreviousStep } = useOnboardingSteps() + + const { addOnboardingAccountMnemonic } = useOnboardingContext() + const { privateKey, resetScantastic, sessionUUID, expirationTimestamp } = useScantasticContext() + const resetFlowAndNavBack = useCallback((): void => { + resetScantastic() + goToPreviousStep() + }, [goToPreviousStep, resetScantastic]) + + const [expiryText, setExpiryText] = useState(getOtpDurationString(expirationTimestamp)) + + const setExpirationText = useCallback(() => { + const expirationString = getOtpDurationString(expirationTimestamp) + setExpiryText(expirationString) + }, [expirationTimestamp]) + useInterval(setExpirationText, ONE_SECOND_MS) + + if (!sessionUUID || !privateKey) { + resetFlowAndNavBack() + } + + const [loading, setLoading] = useState(false) + const [error, setError] = useState(false) + const [failedAttemptCount, setFailedAttemptCount] = useState(0) + const [characterSequence, setCharacterSequence] = useState(INITIAL_CHARACTER_SEQUENCE) + + const inputRefs = useRef[]>([]) + inputRefs.current = new Array(6).fill(null).map((_, i) => inputRefs.current[i] || createRef()) + + // Add all accounts from mnemonic. + const onSubmit = useCallback( + async (mnemonic: string[]) => { + addOnboardingAccountMnemonic(mnemonic) + goToNextStep() + }, + [goToNextStep, addOnboardingAccountMnemonic], + ) + + useEffect(() => { + if (error && !arraysAreEqual(characterSequence, INITIAL_CHARACTER_SEQUENCE)) { + setCharacterSequence(INITIAL_CHARACTER_SEQUENCE) + } + }, [error, characterSequence]) + + const submitOTP = useCallback(async (): Promise => { + if (!privateKey || !sessionUUID) { + return + } + setError(false) + setLoading(true) + // submit OTP to receive blob + const response = await fetch(`${uniswapUrls.scantasticApiUrl}/otp`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + uuid: sessionUUID, + otp: characterSequence.join(''), + }), + }) + + if (!response.ok) { + setCharacterSequence(INITIAL_CHARACTER_SEQUENCE) + throw new Error(`Failed to submit OTP: ${await response.text()}`) + } + + const data = (await response.json()) as { encryptedSeed?: string; OTPFailedAttempts?: number } + if (!data.encryptedSeed) { + if (data.OTPFailedAttempts) { + if (Number(data.OTPFailedAttempts) === MAX_FAILED_OTP_ATTEMPTS) { + resetFlowAndNavBack() + return + } else { + setFailedAttemptCount(data.OTPFailedAttempts) + return + } + } + throw new Error(`fetch(${uniswapUrls.scantasticApiUrl}/otp failed to include an encrypted seed`) + } + const preImage = await decryptMessage(privateKey, data.encryptedSeed) + const words = preImage.split(' ') + + const newMnemonic = Array(24) + .fill('') + .map((_, i) => (words[i] || '') as string) + .filter((word) => !!word) + + await onSubmit(newMnemonic) + }, [privateKey, sessionUUID, characterSequence, onSubmit, resetFlowAndNavBack]) + + const handleChange = useCallback( + (index: number) => + (event: NativeSyntheticEvent): void => { + setError(false) + const newCharacters: CharacterSequence = [...characterSequence] + newCharacters[index] = event.nativeEvent.text + setCharacterSequence(newCharacters) + + if (newCharacters[index]?.length === 1 && inputRefs.current[index + 1]?.current) { + inputRefs.current[index + 1]?.current?.focus() + } + }, + [characterSequence, setCharacterSequence], + ) + + const handleKeyPress = useCallback( + (index: number) => + (event: NativeSyntheticEvent): void => { + if (index !== 0 && event.nativeEvent.key === 'Backspace') { + inputRefs.current[index - 1]?.current?.focus() + } + }, + [], + ) + + useEffect(() => { + const allCharactersFilled = characterSequence.every((element) => element !== '') + if (allCharactersFilled && !loading && !error) { + submitOTP() + .catch((e) => { + inputRefs.current[0]?.current?.focus() + logger.error(e, { + tags: { file: 'OTPInput.tsx', function: 'submitOTP' }, + extra: { uuid: sessionUUID }, + }) + setError(true) + }) + .finally(() => { + setLoading(false) + }) + } + }, [characterSequence, loading, error, sessionUUID, submitOTP]) + + useTimeout(resetFlowAndNavBack, expirationTimestamp - Date.now()) + + return ( + + + + + } + nextButtonEnabled={false} + nextButtonText={expiryText} + nextButtonTheme="secondary" + subtitle={t('onboarding.scan.otp.subtitle')} + title={t('onboarding.scan.otp.title')} + onBack={resetFlowAndNavBack} + onSubmit={(): void => undefined} + > + + + {characterSequence.map((character, index) => ( + + ))} + + + {error && ( + + {t('onboarding.scan.otp.error')} + + )} + {failedAttemptCount > 0 && ( + + {t('onboarding.scan.otp.failed', { number: failedAttemptCount })} + + )} + + + + ) +} diff --git a/apps/extension/src/app/features/onboarding/scan/ScanToOnboard.tsx b/apps/extension/src/app/features/onboarding/scan/ScanToOnboard.tsx new file mode 100644 index 00000000000..e7915bca2fc --- /dev/null +++ b/apps/extension/src/app/features/onboarding/scan/ScanToOnboard.tsx @@ -0,0 +1,311 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { + cancelAnimation, + useAnimatedStyle, + useSharedValue, + withRepeat, + withSequence, + withSpring, +} from 'react-native-reanimated' +import { SpringConfig } from 'react-native-reanimated/lib/typescript/reanimated2/animation/springUtils' +import QRCode from 'react-qr-code' //TODO(EXT-476): Replace with custom QR code designs +import { OnboardingScreen } from 'src/app/features/onboarding/OnboardingScreen' +import { useOnboardingSteps } from 'src/app/features/onboarding/OnboardingSteps' +import { Terms } from 'src/app/features/onboarding/Terms' +import { useScantasticContext } from 'src/app/features/onboarding/scan/ScantasticContextProvider' +import { getScantasticUrl } from 'src/app/features/onboarding/scan/utils' +import { TopLevelRoutes } from 'src/app/navigation/constants' +import { navigate } from 'src/app/navigation/state' +import UAParser from 'ua-parser-js' +import { Flex, Image, Square, Text, useSporeColors } from 'ui/src' +import { DOT_GRID, UNISWAP_LOGO } from 'ui/src/assets' +import { Mobile, Wifi } from 'ui/src/components/icons' +import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' +import { iconSizes, zIndices } from 'ui/src/theme' +import { uniswapUrls } from 'uniswap/src/constants/urls' +import { FeatureFlags } from 'uniswap/src/features/gating/flags' +import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { ExtensionOnboardingFlow, ExtensionOnboardingScreens } from 'uniswap/src/types/screens/extension' +import { logger } from 'utilities/src/logger/logger' +import { ONE_SECOND_MS } from 'utilities/src/time/time' +import { useTimeout } from 'utilities/src/time/timing' +import { ScantasticParamsSchema } from 'wallet/src/features/scantastic/types' + +const UNISWAP_LOGO_SIZE = 52 +const UNISWAP_LOGO_SCALE_LOADING = 1.2 +const UNISWAP_LOGO_SCALE_DEFAULT = 1 +const QR_CODE_SIZE = 212 + +function useDocumentVisibility(): boolean { + const [isDocumentVisible, setIsDocumentVisible] = useState(!document.hidden) + + const handleVisibilityChange = (): void => { + setIsDocumentVisible(!document.hidden) + } + + useEffect(() => { + document.addEventListener('visibilitychange', handleVisibilityChange) + + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange) + } + }, []) + + return isDocumentVisible +} + +export function ScanToOnboard(): JSX.Element { + const colors = useSporeColors() + const { t } = useTranslation() + + const { goToNextStep } = useOnboardingSteps() + const isDocumentVisible = useDocumentVisibility() + + const { sessionUUID, isLoadingUUID, publicKey, resetScantastic, expirationTimestamp, setExpirationTimestamp } = + useScantasticContext() + + const scantasticValue = useMemo(() => { + const parser = new UAParser(window.navigator.userAgent) + const { + device: { vendor, model }, + browser: { name: browser }, + } = parser.getResult() + + if (!publicKey || !sessionUUID) { + return '' + } + + try { + const params = ScantasticParamsSchema.parse({ + uuid: sessionUUID, + publicKey, + vendor, + browser, + model, + }) + + return getScantasticUrl(params) + } catch (e) { + const wrappedError = new Error('Failed to build scantastic params', { cause: e }) + logger.error(wrappedError, { + tags: { + file: 'ScanToOnboard.tsx', + function: 'useMemo', + }, + }) + return '' + } + }, [publicKey, sessionUUID]) + + const errorDerivingQR = Boolean(!isLoadingUUID && !scantasticValue) + + const checkOTPState = useCallback(async (): Promise => { + if (!sessionUUID) { + return + } + try { + // poll OTP state + const response = await fetch(`${uniswapUrls.scantasticApiUrl}/otp-state/${sessionUUID}`, { + method: 'POST', + headers: { + Accept: 'application/json', + }, + }) + + if (!response.ok) { + throw new Error(`Failed to check OTP state: ${await response.text()}`) + } + const data = (await response.json()) as { otp: string; expiresAtInSeconds: number } + const otpState = data.otp + if (!otpState) { + throw new Error(`Scantastic OTP check response did not include the requested OTP state`) + } + + setExpirationTimestamp((current) => data?.expiresAtInSeconds * ONE_SECOND_MS ?? current) + + // mobile app has received the OTP and the user should input it into this UI + if (otpState === 'ready') { + goToNextStep() + } + if (otpState === 'expired') { + resetScantastic() + } + } catch (e) { + logger.error(e, { + tags: { + file: 'ScanToOnboard.tsx', + function: 'checkOTPState', + }, + extra: { uuid: sessionUUID }, + }) + } + }, [sessionUUID, setExpirationTimestamp, goToNextStep, resetScantastic]) + + useEffect(() => { + let interval: NodeJS.Timeout | undefined + + if (isDocumentVisible) { + interval = setInterval(checkOTPState, ONE_SECOND_MS) + } + + return () => clearInterval(interval) + }, [checkOTPState, isDocumentVisible]) + + useTimeout(resetScantastic, expirationTimestamp - Date.now()) + + const qrScale = useSharedValue(UNISWAP_LOGO_SCALE_DEFAULT) + useEffect(() => { + if (!isLoadingUUID) { + qrScale.value = UNISWAP_LOGO_SCALE_DEFAULT + return + } + + const springConfig: SpringConfig = { + mass: 1, + stiffness: 80, + damping: 20, + } + qrScale.value = withRepeat( + withSequence( + withSpring(UNISWAP_LOGO_SCALE_LOADING, springConfig), + withSpring(UNISWAP_LOGO_SCALE_DEFAULT, springConfig), + ), + 0, + true, + ) + + return () => cancelAnimation(qrScale) + }, [isLoadingUUID, qrScale]) + // Using useAnimatedStyle and AnimatedFlex because tamagui scale animation not working + const qrAnimatedStyle = useAnimatedStyle(() => { + return { + transform: `scale(${qrScale.value})`, + } + }, [qrScale]) + + const scantasticOnboardingOnly = useFeatureFlag(FeatureFlags.ScantasticOnboardingOnly) + + return ( + + + + + } + nextButtonEnabled={false} + nextButtonText={ + scantasticOnboardingOnly + ? undefined + : errorDerivingQR + ? t('common.button.retry') + : t('onboarding.scan.button') + } + nextButtonTheme="secondary" + subtitle={t('onboarding.scan.subtitle')} + title={t('onboarding.scan.title')} + onBack={ + scantasticOnboardingOnly + ? undefined + : (): void => navigate(`/${TopLevelRoutes.Onboarding}`, { replace: true }) + } + > + + + {errorDerivingQR ? ( + + + {t('onboarding.scan.error')} + + + ) : ( + <> + {/* + NOTE: if you modify the style or colors of the QR code, make sure to thoroughly test how they perform when scanning them both on light and dark modes. + */} + + + + {isLoadingUUID ? ( + + ) : ( + + + + )} + + )} + + + + + {t('onboarding.scan.wifi')} + + + + {scantasticOnboardingOnly && ( + + + + )} + + + + ) +} diff --git a/apps/extension/src/app/features/onboarding/scan/ScantasticContextProvider.tsx b/apps/extension/src/app/features/onboarding/scan/ScantasticContextProvider.tsx new file mode 100644 index 00000000000..84dd33c8b54 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/scan/ScantasticContextProvider.tsx @@ -0,0 +1,142 @@ +import { + createContext, + Dispatch, + PropsWithChildren, + SetStateAction, + useCallback, + useContext, + useEffect, + useState, +} from 'react' +import { useOnboardingSteps } from 'src/app/features/onboarding/OnboardingSteps' +import { cryptoKeyToJWK, KEY_PARAMS } from 'src/app/features/onboarding/scan/utils' +import { OnboardingRoutes, TopLevelRoutes } from 'src/app/navigation/constants' +import { navigate } from 'src/app/navigation/state' +import { uniswapUrls } from 'uniswap/src/constants/urls' +import { logger } from 'utilities/src/logger/logger' +import { ONE_DAY_MS, ONE_MINUTE_MS, ONE_SECOND_MS } from 'utilities/src/time/time' +import { ScantasticParamsSchema } from 'wallet/src/features/scantastic/types' + +type ScantasticContextState = { + isLoadingUUID: boolean + privateKey: CryptoKey | null + publicKey: JsonWebKey | null + sessionUUID: string | null + resetScantastic: () => void + expirationTimestamp: number + setExpirationTimestamp: Dispatch> +} + +const uuidSchema = ScantasticParamsSchema.shape.uuid + +export const ScantasticContext = createContext(undefined) + +export function ScantasticContextProvider({ children }: PropsWithChildren): JSX.Element { + const { isResetting } = useOnboardingSteps() + + const [isLoadingUUID, setIsLoadingUUID] = useState(true) + const [publicKey, setPublicKey] = useState(null) + const [privateKey, setPrivateKey] = useState(null) + const [sessionUUID, setSessionUUID] = useState(null) + // Users have 20 minutes to scan the QR code. This is reduced to 6 minutes for OTP input once the scan is completed. + const [expirationTimestamp, setExpirationTimestamp] = useState(Date.now() + 20 * ONE_MINUTE_MS) + + const reset = useCallback(() => { + setPublicKey(null) + setPrivateKey(null) + setSessionUUID(null) + setExpirationTimestamp(Date.now() + ONE_DAY_MS) + navigate(`/${TopLevelRoutes.Onboarding}/${isResetting ? OnboardingRoutes.ResetScan : OnboardingRoutes.Scan}`, { + replace: true, + }) + }, [isResetting]) + + useEffect(() => { + async function getSessionUUID(): Promise { + if (sessionUUID) { + return + } + + try { + const { publicKey: pub, privateKey: priv } = await window.crypto.subtle.generateKey(KEY_PARAMS, true, [ + 'encrypt', + 'decrypt', + ]) + const jwk = await cryptoKeyToJWK(pub) + setPublicKey(jwk) + setPrivateKey(priv) + } catch (e) { + logger.error(e, { + tags: { + file: 'OnboardingContextProvider.tsx', + function: 'getSessionUUID->generateKeyPair', + }, + }) + } + + // Initiate scantastic onboarding session + const response = await fetch(`${uniswapUrls.scantasticApiUrl}/uuid`, { + method: 'POST', + headers: { + Accept: 'application/json', + }, + }) + + if (!response.ok) { + throw new Error(`Failed to fetch uuid for mobile->ext onboarding: ${await response.text()}`) + } + + const data = await response.json() + + if (!data.uuid) { + throw new Error('Missing uuid from onboarding session initiation request.') + } + + try { + const uuid = uuidSchema.parse(data.uuid) + setSessionUUID(uuid) + } catch { + throw new Error('Invalid uuid from onboarding session initiation request.') + } + + if (data.expiresAtInSeconds) { + setExpirationTimestamp(data.expiresAtInSeconds * ONE_SECOND_MS) + } + } + + setIsLoadingUUID(true) + getSessionUUID() + .catch((e) => { + logger.error(e, { + tags: { file: 'OnboardingContextProvider.tsx', function: 'getSessionUUID' }, + }) + }) + .finally(() => { + setIsLoadingUUID(false) + }) + }, [sessionUUID]) + + return ( + + {children} + + ) +} + +export const useScantasticContext = (): ScantasticContextState => { + const scantasticContext = useContext(ScantasticContext) + if (scantasticContext === undefined) { + throw new Error('useScantasticContext must be inside a ScantasticContextProvider') + } + return scantasticContext +} diff --git a/apps/extension/src/app/features/onboarding/scan/utils.ts b/apps/extension/src/app/features/onboarding/scan/utils.ts new file mode 100644 index 00000000000..e08cdb84be0 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/scan/utils.ts @@ -0,0 +1,52 @@ +import { logger } from 'utilities/src/logger/logger' +import { ScantasticParams } from 'wallet/src/features/scantastic/types' + +export const KEY_PARAMS = { + name: 'RSA-OAEP', + modulusLength: 4096, + publicExponent: new Uint8Array([1, 0, 1]), + hash: 'SHA-256', +} + +export async function cryptoKeyToJWK(key: CryptoKey): Promise { + const exportedKeyData = await window.crypto.subtle.exportKey('jwk', key) + return exportedKeyData +} + +export function getScantasticUrl({ uuid, publicKey, vendor, model, browser }: ScantasticParams): string { + let qrURI = `uniswap://scantastic?pubKey=${JSON.stringify(publicKey)}&uuid=${encodeURIComponent(uuid)}` + if (vendor) { + qrURI = qrURI.concat(`&vendor=${encodeURIComponent(vendor)}`) + } + if (model) { + qrURI = qrURI.concat(`&model=${encodeURIComponent(model)}`) + } + if (browser) { + qrURI = qrURI.concat(`&browser=${encodeURIComponent(browser)}`) + } + return qrURI +} + +function base64ToArrayBuffer(base64Data: string): ArrayBuffer { + const binaryString = window.atob(base64Data) + const len = binaryString.length + const bytes = new Uint8Array(len) + for (let i = 0; i < len; i++) { + bytes[i] = binaryString.charCodeAt(i) + } + return bytes.buffer +} + +export async function decryptMessage(privateKey: CryptoKey, ciphertext: string): Promise { + const cipherTextBuffer = base64ToArrayBuffer(ciphertext) + + try { + const decryptedArrayBuffer = await window.crypto.subtle.decrypt({ name: 'RSA-OAEP' }, privateKey, cipherTextBuffer) + + const textDecoder = new TextDecoder() + return textDecoder.decode(decryptedArrayBuffer) + } catch (e) { + logger.error(e, { tags: { file: 'scan/utils.ts', function: 'decryptMessage' } }) + return '' + } +} diff --git a/apps/extension/src/app/features/onboarding/utils.ts b/apps/extension/src/app/features/onboarding/utils.ts new file mode 100644 index 00000000000..7e0c93ddb42 --- /dev/null +++ b/apps/extension/src/app/features/onboarding/utils.ts @@ -0,0 +1,2 @@ +export const ONBOARDING_CONTENT_WIDTH = 460 +export const ONBOARDING_INITIAL_FRAME_HEIGHT = 636 diff --git a/apps/extension/src/app/features/popups/ConnectPopup.tsx b/apps/extension/src/app/features/popups/ConnectPopup.tsx new file mode 100644 index 00000000000..f6c9df13cc4 --- /dev/null +++ b/apps/extension/src/app/features/popups/ConnectPopup.tsx @@ -0,0 +1,87 @@ +import { useTranslation } from 'react-i18next' +import { Link } from 'react-router-dom' +import { saveDappConnection } from 'src/app/features/dapp/actions' +import { useDappContext } from 'src/app/features/dapp/DappContext' +import { extractUrlHost } from 'src/app/features/dappRequests/utils' +import { Anchor, Button, Flex, Popover, Separator, Text, TouchableArea } from 'ui/src' +import { X } from 'ui/src/components/icons' +import { uniswapUrls } from 'uniswap/src/constants/urls' +import { ExtensionEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { useActiveAccountWithThrow } from 'wallet/src/features/wallet/hooks' + +export function ConnectPopupContent({ + onClose, + asPopover = false, + showConnectButton = false, +}: { + onClose?: () => void + asPopover?: boolean + showConnectButton?: boolean +}): JSX.Element { + const { t } = useTranslation() + + const { dappUrl } = useDappContext() + const activeAccount = useActiveAccountWithThrow() + + const onConnect = async (): Promise => { + await saveDappConnection(dappUrl, activeAccount) + onClose?.() + } + + return ( + + + + {t('extension.connection.titleNotConnected')} + + + + {extractUrlHost(dappUrl)} + + + + + {!asPopover && ( + + + + )} + + + + + {showConnectButton ? t('extension.connection.popupWithButton') : t('extension.connection.popup')} + + {showConnectButton ? ( + asPopover ? ( + + + + ) : ( + + ) + ) : ( + + sendAnalyticsEvent(ExtensionEventName.DappTroubleConnecting, { + dappUrl, + }) + } + > + + {t('extension.connection.popup.trouble')} + + + )} + + + ) +} diff --git a/apps/extension/src/app/features/popups/selectors.ts b/apps/extension/src/app/features/popups/selectors.ts new file mode 100644 index 00000000000..6c8c7d3193d --- /dev/null +++ b/apps/extension/src/app/features/popups/selectors.ts @@ -0,0 +1,6 @@ +import { PopupsState } from 'src/app/features/popups/slice' +import { WebState } from 'src/store/webReducer' + +export function selectPopupState(name: T): (state: WebState) => PopupsState[T] { + return (state) => state.popups[name] +} diff --git a/apps/extension/src/app/features/popups/slice.ts b/apps/extension/src/app/features/popups/slice.ts new file mode 100644 index 00000000000..a11c446698a --- /dev/null +++ b/apps/extension/src/app/features/popups/slice.ts @@ -0,0 +1,33 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit' + +export enum PopupName { + Connect = 'connect', +} + +export interface PopupsState { + [PopupName.Connect]: { + isOpen: boolean + } +} + +const initialState: PopupsState = { + [PopupName.Connect]: { + isOpen: false, + }, +} + +const slice = createSlice({ + name: 'popups', + initialState, + reducers: { + openPopup: (state, action: PayloadAction) => { + state[action.payload].isOpen = true + }, + closePopup: (state, action: PayloadAction) => { + state[action.payload].isOpen = false + }, + }, +}) + +export const { openPopup, closePopup } = slice.actions +export const { reducer: popupsReducer } = slice diff --git a/apps/extension/src/app/features/receive/ReceiveScreen.test.tsx b/apps/extension/src/app/features/receive/ReceiveScreen.test.tsx new file mode 100644 index 00000000000..fb640ab5706 --- /dev/null +++ b/apps/extension/src/app/features/receive/ReceiveScreen.test.tsx @@ -0,0 +1,24 @@ +import { ReceiveScreen } from 'src/app/features/receive/ReceiveScreen' +import { cleanup, render, screen } from 'src/test/test-utils' +import { ACCOUNT, preloadedSharedState } from 'wallet/src/test/fixtures' + +const preloadedState = preloadedSharedState({ + account: ACCOUNT, +}) + +describe(ReceiveScreen, () => { + it('renders without error', async () => { + const tree = render(, { preloadedState }) + + expect(tree).toMatchSnapshot() + cleanup() + }) + + it('renders a QR code', async () => { + render(, { preloadedState }) + + const qrCode = await screen.getByTestId('wallet-qr-code') + expect(qrCode).toBeDefined() + cleanup() + }) +}) diff --git a/apps/extension/src/app/features/receive/ReceiveScreen.tsx b/apps/extension/src/app/features/receive/ReceiveScreen.tsx new file mode 100644 index 00000000000..ac8ebfb2e50 --- /dev/null +++ b/apps/extension/src/app/features/receive/ReceiveScreen.tsx @@ -0,0 +1,29 @@ +import { useTranslation } from 'react-i18next' +import { ScreenHeader } from 'src/app/components/layout/ScreenHeader' +import { SCREEN_ITEM_HORIZONTAL_PAD } from 'src/app/constants' +import { useExtensionNavigation } from 'src/app/navigation/utils' +import { Flex } from 'ui/src' +import { X } from 'ui/src/components/icons' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { WalletQRCode } from 'wallet/src/components/QRCodeScanner/WalletQRCode' +import { useActiveAccountAddressWithThrow } from 'wallet/src/features/wallet/hooks' + +export function ReceiveScreen(): JSX.Element { + const { t } = useTranslation() + const { navigateBack } = useExtensionNavigation() + const activeAddress = useActiveAccountAddressWithThrow() + + return ( + + + + + + + + + + + ) +} diff --git a/apps/extension/src/app/features/receive/__snapshots__/ReceiveScreen.test.tsx.snap b/apps/extension/src/app/features/receive/__snapshots__/ReceiveScreen.test.tsx.snap new file mode 100644 index 00000000000..780be3ab591 --- /dev/null +++ b/apps/extension/src/app/features/receive/__snapshots__/ReceiveScreen.test.tsx.snap @@ -0,0 +1,12505 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ReceiveScreen renders without error 1`] = ` +{ + "asFragment": [Function], + "baseElement": +
+ + +
+
+
+
+ + + +
+
+ + Receive + +
+
+
+
+
+
+
+
+
+
+ + Test Account + +
+
+
+
+ + 0x​82D5...3Fa6 + + + + +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + + + +
+
+
+
+
+
+
+ +
+
+
+ +
+
+
+
+ +
+
+
+ +
+
+
+
+ +
+
+
+ +
+
+
+
+ +
+
+
+ +
+
+
+
+ +
+
+
+ +
+
+
+
+ +
+
+
+ +
+
+
+
+ +
+
+
+ +
+
+
+
+ +
+
+
+ +
+
+
+
+ +
+
+
+ +
+
+
+
+ +
+
+
+ +
+
+
+
+ +
+
+
+ +
+
+
+
+ + You can receive tokens & NFTs on Ethereum, Polygon, Arbitrum, Optimism, Base, ZKsync, Zora, Avalanche, Celo, Blast, and BNB Chain. + +
+ + Learn more + +
+
+
+
+ + +
+ , + "container":
+ + +
+
+
+
+ + + +
+
+ + Receive + +
+
+
+
+
+
+
+
+
+
+ + Test Account + +
+
+
+
+ + 0x​82D5...3Fa6 + + + + +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + + + +
+
+
+
+
+
+
+ +
+
+
+ +
+
+
+
+ +
+
+
+ +
+
+
+
+ +
+
+
+ +
+
+
+
+ +
+
+
+ +
+
+
+
+ +
+
+
+ +
+
+
+
+ +
+
+
+ +
+
+
+
+ +
+
+
+ +
+
+
+
+ +
+
+
+ +
+
+
+
+ +
+
+
+ +
+
+
+
+ +
+
+
+ +
+
+
+
+ +
+
+
+ +
+
+
+
+ + You can receive tokens & NFTs on Ethereum, Polygon, Arbitrum, Optimism, Base, ZKsync, Zora, Avalanche, Celo, Blast, and BNB Chain. + +
+ + Learn more + +
+
+
+
+ + +
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "store": { + "@@observable": [Function], + "dispatch": [Function], + "getState": [Function], + "replaceReducer": [Function], + "subscribe": [Function], + }, + "unmount": [Function], +} +`; diff --git a/apps/extension/src/app/features/settings/DevMenuScreen.tsx b/apps/extension/src/app/features/settings/DevMenuScreen.tsx new file mode 100644 index 00000000000..5436dceee09 --- /dev/null +++ b/apps/extension/src/app/features/settings/DevMenuScreen.tsx @@ -0,0 +1,44 @@ +import { useTranslation } from 'react-i18next' +import { ScreenHeader } from 'src/app/components/layout/ScreenHeader' +import { SettingsItemWithDropdown } from 'src/app/features/settings/SettingsItemWithDropdown' +import { Accordion, Flex, ScrollView } from 'ui/src' +import { Settings } from 'ui/src/components/icons' +import i18n from 'uniswap/src/i18n/i18n' +import { GatingOverrides } from 'wallet/src/components/gating/GatingOverrides' +import { Language, SUPPORTED_LANGUAGES } from 'wallet/src/features/language/constants' +import { getLanguageInfo, useCurrentLanguageInfo } from 'wallet/src/features/language/hooks' +import { setCurrentLanguage } from 'wallet/src/features/language/slice' +import { useAppDispatch } from 'wallet/src/state' + +export function DevMenuScreen(): JSX.Element { + const { t } = useTranslation() + const dispatch = useAppDispatch() + + // Changing extension language requires changing system settings, so allowing for easy override here + const currentLanguageInfo = useCurrentLanguageInfo() + + return ( + + + + { + return { value: language, label: getLanguageInfo(t, language).displayName } + })} + selected={currentLanguageInfo.displayName} + title="Language Override" + onSelect={async (value) => { + const language = value as Language + const languageInfo = getLanguageInfo(t, language) + await i18n.changeLanguage(languageInfo.locale) + dispatch(setCurrentLanguage(language)) + }} + /> + + + + + + ) +} diff --git a/apps/extension/src/app/features/settings/SettingsDropdown.tsx b/apps/extension/src/app/features/settings/SettingsDropdown.tsx new file mode 100644 index 00000000000..be9ee09cd86 --- /dev/null +++ b/apps/extension/src/app/features/settings/SettingsDropdown.tsx @@ -0,0 +1,92 @@ +import { useState } from 'react' +import { Flex, Popover, ScrollView, Text, TouchableArea } from 'ui/src' +import { Check, RotatableChevron } from 'ui/src/components/icons' +import { iconSizes } from 'ui/src/theme' + +type DropdownItem = { + label: string + value: unknown +} + +export type SettingsDropdownProps = { + selected: string + items: DropdownItem[] + disableDropdown?: boolean + onSelect: (item: unknown) => void +} + +const MAX_DROPDOWN_HEIGHT = 220 +const MAX_DROPDOWN_WIDTH = 200 + +export function SettingsDropdown({ selected, items, disableDropdown, onSelect }: SettingsDropdownProps): JSX.Element { + const [isOpen, setIsOpen] = useState(false) + + return ( + + setIsOpen(open)}> + + + + {selected} + + + + + + + + + + {items.map((item, index) => ( + { + onSelect(item.value) + setIsOpen(false) + }} + > + + + + {item.label} + + + {selected === item.label ? ( + + ) : ( + + )} + + + ))} + + + + + + + ) +} diff --git a/apps/extension/src/app/features/settings/SettingsItemWithDropdown.tsx b/apps/extension/src/app/features/settings/SettingsItemWithDropdown.tsx new file mode 100644 index 00000000000..a7f1c166cb4 --- /dev/null +++ b/apps/extension/src/app/features/settings/SettingsItemWithDropdown.tsx @@ -0,0 +1,33 @@ +import { SCREEN_ITEM_HORIZONTAL_PAD } from 'src/app/constants' +import { SettingsDropdown, SettingsDropdownProps } from 'src/app/features/settings/SettingsDropdown' +import { Flex, GeneratedIcon, Text, TouchableArea } from 'ui/src' +import { iconSizes } from 'ui/src/theme' + +type SettingsItemWithDropdownProps = { + Icon: GeneratedIcon + title: string + disableDropdown?: boolean + onDisabledDropdownPress?: () => void +} & SettingsDropdownProps + +export function SettingsItemWithDropdown(props: SettingsItemWithDropdownProps): JSX.Element { + const { title, disableDropdown, Icon, onDisabledDropdownPress, ...dropdownProps } = props + + const dropdown = + + return ( + + + + + {title} + + + {disableDropdown ? ( + onDisabledDropdownPress?.()}>{dropdown} + ) : ( + dropdown + )} + + ) +} diff --git a/apps/extension/src/app/features/settings/SettingsPrivacyScreen.tsx b/apps/extension/src/app/features/settings/SettingsPrivacyScreen.tsx new file mode 100644 index 00000000000..03d2036640a --- /dev/null +++ b/apps/extension/src/app/features/settings/SettingsPrivacyScreen.tsx @@ -0,0 +1,15 @@ +import { useTranslation } from 'react-i18next' +import { ScreenHeader } from 'src/app/components/layout/ScreenHeader' +import { Flex } from 'ui/src' +import { AnalyticsToggleLineSwitch } from 'wallet/src/components/settings/AnalyticsToggleLineSwitch' + +export function SettingsPrivacyScreen(): JSX.Element { + const { t } = useTranslation() + + return ( + + + + + ) +} diff --git a/apps/extension/src/app/features/settings/SettingsRecoveryPhraseScreen/RemoveRecoveryPhraseVerify.tsx b/apps/extension/src/app/features/settings/SettingsRecoveryPhraseScreen/RemoveRecoveryPhraseVerify.tsx new file mode 100644 index 00000000000..2f0fe5990e7 --- /dev/null +++ b/apps/extension/src/app/features/settings/SettingsRecoveryPhraseScreen/RemoveRecoveryPhraseVerify.tsx @@ -0,0 +1,141 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { PasswordInput } from 'src/app/components/PasswordInput' +import { ScreenHeader } from 'src/app/components/layout/ScreenHeader' +import { removeAllDappConnectionsFromExtension } from 'src/app/features/dapp/actions' +import { SettingsRecoveryPhrase } from 'src/app/features/settings/SettingsRecoveryPhraseScreen/SettingsRecoveryPhrase' +import { focusOrCreateOnboardingTab } from 'src/app/navigation/utils' +import { useAppDispatch } from 'src/store/store' +import { CheckBox, Flex, Text, inputStyles } from 'ui/src' +import { TrashFilled } from 'ui/src/components/icons' +import { WalletEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { logger } from 'utilities/src/logger/logger' +import { Keyring } from 'wallet/src/features/wallet/Keyring/Keyring' +import { EditAccountAction, editAccountActions } from 'wallet/src/features/wallet/accounts/editAccountSaga' +import { useSignerAccounts } from 'wallet/src/features/wallet/hooks' + +export function RemoveRecoveryPhraseVerify(): JSX.Element { + const { t } = useTranslation() + const dispatch = useAppDispatch() + + const [password, setPassword] = useState('') + const [showPasswordError, setShowPasswordError] = useState(false) + const [hideInput, setHideInput] = useState(true) + const [checked, setChecked] = useState(false) + + const onChangeText = (text: string): void => { + setPassword(text) + setShowPasswordError(false) + } + + const onCheckPressed = (): void => { + setChecked(!checked) + } + + const associatedAccounts = useSignerAccounts() + + const onRemove = async (): Promise => { + const accountsToRemove = associatedAccounts + const mnemonicId = accountsToRemove?.[0]?.mnemonicId + const accAddress = accountsToRemove?.[0]?.address + + if (!accAddress) { + logger.error(new Error('No accounts to remove'), { + tags: { file: 'RemoveRecoveryPhraseVerify', function: 'onRemove' }, + }) + return + } + + if (!mnemonicId) { + logger.error(new Error('mnemonicId does not exist'), { + tags: { file: 'RemoveRecoveryPhraseVerify', function: 'onRemove' }, + }) + return + } + + await Keyring.removeMnemonic(mnemonicId) + await Keyring.removePassword() + + await removeAllDappConnectionsFromExtension() + + await dispatch( + editAccountActions.trigger({ + type: EditAccountAction.Remove, + accounts: accountsToRemove, + }), + ) + + sendAnalyticsEvent(WalletEventName.WalletRemoved, { + wallets_removed: accountsToRemove.map((a) => a.address), + }) + + await focusOrCreateOnboardingTab() + window.close() + } + + const checkPassword = async (): Promise => { + if (!checked) { + return + } + const success = await Keyring.checkPassword(password) + if (!success) { + setShowPasswordError(true) + return + } + await onRemove() + } + + const removeButtonEnabled = checked && !showPasswordError && password.length > 0 + + return ( + + + } + nextButtonEnabled={removeButtonEnabled} + nextButtonText={t('setting.recoveryPhrase.remove')} + nextButtonTheme="detrimental_Button" + subtitle={t('setting.recoveryPhrase.remove.subtitle')} + title={t('setting.recoveryPhrase.remove.title')} + onNextPressed={checkPassword} + > + + + + + {showPasswordError ? t('setting.recoveryPhrase.remove.password.error') : ''} + + + + + + {t('setting.recoveryPhrase.remove.confirm.title')} + + + {t('setting.recoveryPhrase.remove.confirm.subtitle')} + + + } + onCheckPressed={onCheckPressed} + /> + + + + + ) +} diff --git a/apps/extension/src/app/features/settings/SettingsRecoveryPhraseScreen/RemoveRecoveryPhraseWallets.tsx b/apps/extension/src/app/features/settings/SettingsRecoveryPhraseScreen/RemoveRecoveryPhraseWallets.tsx new file mode 100644 index 00000000000..c6b25f871ba --- /dev/null +++ b/apps/extension/src/app/features/settings/SettingsRecoveryPhraseScreen/RemoveRecoveryPhraseWallets.tsx @@ -0,0 +1,111 @@ +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { ScreenHeader } from 'src/app/components/layout/ScreenHeader' +import { SettingsRecoveryPhrase } from 'src/app/features/settings/SettingsRecoveryPhraseScreen/SettingsRecoveryPhrase' +import { AppRoutes, RemoveRecoveryPhraseRoutes, SettingsRoutes } from 'src/app/navigation/constants' +import { useExtensionNavigation } from 'src/app/navigation/utils' +import { Flex, ScrollView, Text } from 'ui/src' +import { AlertTriangle } from 'ui/src/components/icons' +import { iconSizes } from 'ui/src/theme' +import { NumberType } from 'utilities/src/format/types' +import { AddressDisplay } from 'wallet/src/components/accounts/AddressDisplay' +import { useAccountList } from 'wallet/src/features/accounts/hooks' +import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' +import { Account } from 'wallet/src/features/wallet/accounts/types' +import { useSignerAccounts } from 'wallet/src/features/wallet/hooks' + +export function RemoveRecoveryPhraseWallets(): JSX.Element { + const { t } = useTranslation() + const { navigateTo } = useExtensionNavigation() + + const accounts = useSignerAccounts() + + return ( + + + } + nextButtonEnabled={true} + nextButtonText={t('common.button.continue')} + nextButtonTheme="secondary_Button" + subtitle={t('setting.recoveryPhrase.remove.initial.subtitle')} + title={t('setting.recoveryPhrase.remove.initial.title')} + onNextPressed={(): void => { + navigateTo( + `${AppRoutes.Settings}/${SettingsRoutes.RemoveRecoveryPhrase}/${RemoveRecoveryPhraseRoutes.Verify}`, + ) + }} + > + + + + ) +} + +// TODO(@thomasthachil): merge this with mobile AccountList +function AssociatedAccountsList({ accounts }: { accounts: Account[] }): JSX.Element { + const addresses = useMemo(() => accounts.map((account) => account.address), [accounts]) + const { data, loading } = useAccountList({ + addresses, + notifyOnNetworkStatusChange: true, + }) + + const sortedAddressesByBalance = addresses + .map((address) => { + const wallet = data?.portfolios?.find((portfolio) => portfolio?.ownerAddress === address) + return { address, balance: wallet?.tokensTotalDenominatedValue?.value } + }) + .sort((a, b) => (b.balance ?? 0) - (a.balance ?? 0)) + + return ( + + + {sortedAddressesByBalance.map(({ address, balance }, index) => ( + + ))} + + + ) +} + +function AssociatedAccountRow({ + index, + address, + balance, + totalCount, + loading, +}: { + index: number + address: string + balance: number | undefined + totalCount: number + loading: boolean +}): JSX.Element { + const { convertFiatAmountFormatted } = useLocalizationContext() + const balanceFormatted = convertFiatAmountFormatted(balance, NumberType.PortfolioBalance) + + return ( + + + + + + {balanceFormatted} + + + ) +} diff --git a/apps/extension/src/app/features/settings/SettingsRecoveryPhraseScreen/SettingsRecoveryPhrase.tsx b/apps/extension/src/app/features/settings/SettingsRecoveryPhraseScreen/SettingsRecoveryPhrase.tsx new file mode 100644 index 00000000000..654fb831a27 --- /dev/null +++ b/apps/extension/src/app/features/settings/SettingsRecoveryPhraseScreen/SettingsRecoveryPhrase.tsx @@ -0,0 +1,52 @@ +import { Button, Flex, Square, Text } from 'ui/src' +import { ThemeNames } from 'ui/src/theme' + +export type SettingsRecoveryPhraseProps = { + title: string + subtitle: string + icon: React.ReactNode + nextButtonEnabled: boolean + nextButtonText: string + nextButtonTheme: string + onNextPressed: () => void + children: React.ReactNode +} +export function SettingsRecoveryPhrase({ + title, + subtitle, + icon, + nextButtonEnabled, + nextButtonText, + nextButtonTheme, + onNextPressed, + children, +}: SettingsRecoveryPhraseProps): JSX.Element { + return ( + + + + {icon} + + + + {title} + + + {subtitle} + + + + {children} + + + + + ) +} diff --git a/apps/extension/src/app/features/settings/SettingsRecoveryPhraseScreen/ViewRecoveryPhraseScreen.tsx b/apps/extension/src/app/features/settings/SettingsRecoveryPhraseScreen/ViewRecoveryPhraseScreen.tsx new file mode 100644 index 00000000000..fa1c6d708d8 --- /dev/null +++ b/apps/extension/src/app/features/settings/SettingsRecoveryPhraseScreen/ViewRecoveryPhraseScreen.tsx @@ -0,0 +1,246 @@ +import { useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { LayoutChangeEvent } from 'react-native' +import { CopyButton } from 'src/app/components/buttons/CopyButton' +import { ScreenHeader } from 'src/app/components/layout/ScreenHeader' +import { EnterPasswordModal } from 'src/app/features/settings/password/EnterPasswordModal' +import { SettingsRecoveryPhrase } from 'src/app/features/settings/SettingsRecoveryPhraseScreen/SettingsRecoveryPhrase' +import { AppRoutes, RemoveRecoveryPhraseRoutes, SettingsRoutes } from 'src/app/navigation/constants' +import { navigate } from 'src/app/navigation/state' +import { Button, Flex, Separator, Text } from 'ui/src' +import { AlertTriangle, Eye, Key, Laptop } from 'ui/src/components/icons' +import { spacing } from 'ui/src/theme' +import { WalletEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { logger } from 'utilities/src/logger/logger' +import { useAsyncData } from 'utilities/src/react/hooks' +import { useSignerAccounts } from 'wallet/src/features/wallet/hooks' +import { Keyring } from 'wallet/src/features/wallet/Keyring/Keyring' +import { setClipboard } from 'wallet/src/utils/clipboard' + +const enum ViewStep { + Warning, + Password, + Reveal, +} + +export function SettingsViewRecoveryPhraseScreen(): JSX.Element { + const { t } = useTranslation() + + const [viewStep, setViewStep] = useState(ViewStep.Warning) + + const mnemonicAccounts = useSignerAccounts() + const mnemonicAccount = mnemonicAccounts[0] + if (!mnemonicAccount) { + throw new Error('Screen should not be accessed unless mnemonic account exists') + } + + const placeholderWordArrayLength = 12 + + const recoveryPhraseString = useAsyncData( + useCallback(async () => Keyring.retrieveMnemonicUnlocked(mnemonicAccount.mnemonicId), [mnemonicAccount.mnemonicId]), + ).data + const recoveryPhraseArray = recoveryPhraseString?.split(' ') ?? Array(placeholderWordArrayLength).fill('') + + const onCopyPress = async (): Promise => { + try { + if (recoveryPhraseString) { + await setClipboard(recoveryPhraseString) + } + } catch (error) { + logger.error(error, { + tags: { file: 'SettingsViewRecoveryPhraseScreen.tsx', function: 'onCopyPress' }, + }) + } + } + + const showPasswordModal = (): void => { + setViewStep(ViewStep.Password) + } + + useEffect(() => { + sendAnalyticsEvent(WalletEventName.ViewRecoveryPhrase) + + // Clear clipboard when the component unmounts + return () => { + navigator.clipboard.writeText('').catch((error) => { + logger.error(error, { + tags: { file: 'SettingsViewRecoveryPhraseScreen.tsx', function: 'maybeClearClipboard' }, + }) + }) + } + }, []) + + return ( + + + {viewStep !== ViewStep.Reveal ? ( + } + nextButtonEnabled={true} + nextButtonText={t('common.button.continue')} + nextButtonTheme="secondary_Button" + subtitle={t('setting.recoveryPhrase.view.warning.message1')} + title={t('setting.recoveryPhrase.view.warning.title')} + onNextPressed={showPasswordModal} + > + {viewStep === ViewStep.Password && ( + setViewStep(ViewStep.Warning)} + onNext={() => setViewStep(ViewStep.Reveal)} + /> + )} + + + + + + + {t('setting.recoveryPhrase.view.warning.message2')} + + + + + + + + {t('setting.recoveryPhrase.view.warning.message3')} + + + + + + + + {t('setting.recoveryPhrase.view.warning.message4')} + + + + + ) : ( + + + + + + + + + + + + {t('setting.recoveryPhrase.warning.view.message')} + + + + + + + )} + + ) +} + +function SeedPhraseColumnGroup({ recoveryPhraseArray }: { recoveryPhraseArray: string[] }): JSX.Element { + const [largestIndexWidth, setLargestIndexWidth] = useState(0) + + const halfLength = recoveryPhraseArray.length / 2 + const firstHalfWords = recoveryPhraseArray.slice(0, halfLength) + const secondHalfWords = recoveryPhraseArray.slice(halfLength) + + const onIndexLayout = (event: LayoutChangeEvent): void => { + const { width } = event.nativeEvent.layout + if (width > largestIndexWidth) { + setLargestIndexWidth(width) + } + } + + return ( + + + + + + ) +} + +function SeedPhraseColumn({ + words, + indexOffset, + largestIndexWidth, + onIndexLayout, +}: { + words: string[] + indexOffset: number + largestIndexWidth: number + onIndexLayout: (event: LayoutChangeEvent) => void +}): JSX.Element { + return ( + + {words.map((word, index) => ( + + ))} + + ) +} + +function SeedPhraseWord({ + index, + word, + indexMinWidth, + onIndexLayout, +}: { + index: number + word: string + indexMinWidth: number + onIndexLayout: (event: LayoutChangeEvent) => void +}): JSX.Element { + return ( + + + {index} + + {word} + + ) +} diff --git a/apps/extension/src/app/features/settings/SettingsScreen.tsx b/apps/extension/src/app/features/settings/SettingsScreen.tsx new file mode 100644 index 00000000000..65d3c01317c --- /dev/null +++ b/apps/extension/src/app/features/settings/SettingsScreen.tsx @@ -0,0 +1,284 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Link } from 'react-router-dom' +import { ScreenHeader } from 'src/app/components/layout/ScreenHeader' +import { SCREEN_ITEM_HORIZONTAL_PAD } from 'src/app/constants' +import { SettingsItemWithDropdown } from 'src/app/features/settings/SettingsItemWithDropdown' +import { AppRoutes, SettingsRoutes } from 'src/app/navigation/constants' +import { useExtensionNavigation } from 'src/app/navigation/utils' +import { useAppDispatch } from 'src/store/store' +import { + Button, + ColorTokens, + Flex, + GeneratedIcon, + ScrollView, + Separator, + Text, + TouchableArea, + useSporeColors, +} from 'ui/src' +import { + Chart, + Coins, + Feedback, + FileListLock, + HelpCenter, + Key, + Language, + LineChartDots, + Lock, + RotatableChevron, + Settings, + ShieldQuestion, +} from 'ui/src/components/icons' +import { iconSizes } from 'ui/src/theme' +import { uniswapUrls } from 'uniswap/src/constants/urls' +import { FeatureFlags } from 'uniswap/src/features/gating/flags' +import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' +import { isDevEnv } from 'utilities/src/environment' +import noop from 'utilities/src/react/noop' +import { WebSwitch } from 'wallet/src/components/buttons/Switch' +import { SettingsLanguageModal } from 'wallet/src/components/settings/language/SettingsLanguageModal' +import { authActions } from 'wallet/src/features/auth/saga' +import { AuthActionType } from 'wallet/src/features/auth/types' +import { FiatCurrency, ORDERED_CURRENCIES } from 'wallet/src/features/fiatCurrency/constants' +import { getFiatCurrencyName, useAppFiatCurrencyInfo } from 'wallet/src/features/fiatCurrency/hooks' +import { setCurrentFiatCurrency } from 'wallet/src/features/fiatCurrency/slice' +import { useCurrentLanguageInfo } from 'wallet/src/features/language/hooks' +import { useHideSmallBalancesSetting, useHideSpamTokensSetting } from 'wallet/src/features/wallet/hooks' +import { setHideSmallBalances, setHideSpamTokens } from 'wallet/src/features/wallet/slice' + +const manifestVersion = chrome.runtime.getManifest().version + +export function SettingsScreen(): JSX.Element { + const { t } = useTranslation() + const dispatch = useAppDispatch() + const { navigateTo, navigateBack } = useExtensionNavigation() + const currentLanguageInfo = useCurrentLanguageInfo() + const appFiatCurrencyInfo = useAppFiatCurrencyInfo() + const isExtensionFeedbackEnabled = useFeatureFlag(FeatureFlags.ExtensionBetaFeedbackPrompt) + + const [isLanguageModalOpen, setIsLanguageModalOpen] = useState(false) + + const onPressLockWallet = async (): Promise => { + navigateBack() + await dispatch(authActions.trigger({ type: AuthActionType.Lock })) + } + + const hideSpamTokens = useHideSpamTokensSetting() + const handleSpamTokensToggle = async (): Promise => { + await dispatch(setHideSpamTokens(!hideSpamTokens)) + } + + const hideSmallBalances = useHideSmallBalancesSetting() + const handleSmallBalancesToggle = async (): Promise => { + await dispatch(setHideSmallBalances(!hideSmallBalances)) + } + + return ( + <> + {isLanguageModalOpen ? setIsLanguageModalOpen(false)} /> : undefined} + + + + + <> + {isDevEnv() && ( + navigateTo(`${AppRoutes.Settings}/${SettingsRoutes.DevMenu}`)} + /> + )} + + { + setIsLanguageModalOpen(true) + }} + onSelect={noop} + /> + { + return { + label: getFiatCurrencyName(t, currency).shortName, + value: currency, + } + })} + selected={appFiatCurrencyInfo.shortName} + title={t('settings.setting.currency.title')} + onSelect={(value) => { + const currency = value as FiatCurrency + dispatch(setCurrentFiatCurrency(currency)) + }} + /> + + + navigateTo(`${AppRoutes.Settings}/${SettingsRoutes.Privacy}`)} + /> + + + + navigateTo(`${AppRoutes.Settings}/${SettingsRoutes.ChangePassword}`)} + /> + navigateTo(`${AppRoutes.Settings}/${SettingsRoutes.ViewRecoveryPhrase}`)} + /> + + + + + {isExtensionFeedbackEnabled ? ( + + ) : ( + <> + )} + {`Version ${manifestVersion}`} + + + + + + ) +} + +function SettingsItem({ + Icon, + title, + onPress, + iconProps, + themeProps, + url, +}: { + Icon: GeneratedIcon + title: string + onPress?: () => void + iconProps?: { strokeWidth?: number } + // TODO: do this with a wrapping Theme, "detrimental" wasn't working + themeProps?: { color?: string; hoverColor?: string } + url?: string +}): JSX.Element { + const colors = useSporeColors() + const hoverColor = themeProps?.hoverColor ?? colors.surface2.val + + const content = ( + + + + + {title} + + + + + ) + + if (url) { + return ( + + {content} + + ) + } + + return content +} + +function SettingsToggleRow({ + Icon, + title, + value, + onValueChange, +}: { + title: string + Icon: GeneratedIcon + value: boolean + onValueChange: (value: boolean) => void +}): JSX.Element { + return ( + + + + {title} + + + + ) +} + +function SettingsSection({ title, children }: { title: string; children: JSX.Element | JSX.Element[] }): JSX.Element { + return ( + + + {title} + + {children} + + ) +} + +function SettingsSectionSeparator(): JSX.Element { + return ( + + + + ) +} diff --git a/apps/extension/src/app/features/settings/SettingsScreenWrapper.tsx b/apps/extension/src/app/features/settings/SettingsScreenWrapper.tsx new file mode 100644 index 00000000000..eaeb5f208f4 --- /dev/null +++ b/apps/extension/src/app/features/settings/SettingsScreenWrapper.tsx @@ -0,0 +1,13 @@ +import { Outlet } from 'react-router-dom' +import { Flex } from 'ui/src' + +/** + * SettingsScreenWrapper is a wrapper used by all settings screens. + */ +export function SettingsScreenWrapper(): JSX.Element { + return ( + + + + ) +} diff --git a/apps/extension/src/app/features/settings/password/ChangePasswordForm.tsx b/apps/extension/src/app/features/settings/password/ChangePasswordForm.tsx new file mode 100644 index 00000000000..f6e0af28254 --- /dev/null +++ b/apps/extension/src/app/features/settings/password/ChangePasswordForm.tsx @@ -0,0 +1,75 @@ +import { useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { PADDING_STRENGTH_INDICATOR, PasswordInput } from 'src/app/components/PasswordInput' +import { useAppDispatch } from 'src/store/store' +import { Button, Flex, Text } from 'ui/src' +import { ExtensionEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { pushNotification } from 'wallet/src/features/notifications/slice' +import { AppNotificationType } from 'wallet/src/features/notifications/types' +import { Keyring } from 'wallet/src/features/wallet/Keyring/Keyring' +import { usePasswordForm } from 'wallet/src/utils/password' + +export function ChangePasswordForm({ onNext }: { onNext: () => void }): JSX.Element { + const { t } = useTranslation() + const dispatch = useAppDispatch() + + const { + enableNext, + hideInput, + debouncedPasswordStrength, + password, + onPasswordBlur, + onChangePassword, + confirmPassword, + onChangeConfirmPassword, + setHideInput, + errorText, + checkSubmit, + } = usePasswordForm() + + const onSubmit = useCallback(async () => { + if (checkSubmit()) { + await Keyring.changePassword(password) + onNext() + dispatch(pushNotification({ type: AppNotificationType.PasswordChanged })) + sendAnalyticsEvent(ExtensionEventName.PasswordChanged) + } + }, [checkSubmit, password, onNext, dispatch]) + + return ( + + + + + + {errorText || 'Placeholder text'} + + + + + ) +} diff --git a/apps/extension/src/app/features/settings/password/EnterPasswordForm.tsx b/apps/extension/src/app/features/settings/password/EnterPasswordForm.tsx new file mode 100644 index 00000000000..bb83e733f23 --- /dev/null +++ b/apps/extension/src/app/features/settings/password/EnterPasswordForm.tsx @@ -0,0 +1,80 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { PasswordInput } from 'src/app/components/PasswordInput' +import { Button, Flex, Text } from 'ui/src' +import { Keyring } from 'wallet/src/features/wallet/Keyring/Keyring' + +function useEnterPasswordForm(): { + password: string + submitEnabled: boolean + error: string + onInputChange: (input: string) => void + onSubmit: () => Promise +} { + const { t } = useTranslation() + const [password, setPassword] = useState('') + const [submitEnabled, setSubmitEnabled] = useState(false) + const [error, setError] = useState('') + + const onInputChange = function onInputChange(input: string): void { + setPassword(input) + setSubmitEnabled(!!input) + setError('') + } + + const onSubmit = async function onSubmit(): Promise { + const success = await Keyring.checkPassword(password) + if (!success) { + setError(t('extension.settings.password.error.wrong')) + } + return success + } + + return { + password, + submitEnabled, + error, + onInputChange, + onSubmit, + } +} + +export function EnterPasswordForm({ onNext }: { onNext: () => void }): JSX.Element { + const { t } = useTranslation() + const [hideInput, setHideInput] = useState(true) + const { password, submitEnabled, error, onInputChange, onSubmit } = useEnterPasswordForm() + + const onContinue = async (): Promise => { + const success = await onSubmit() + if (success) { + onNext() + } + } + + return ( + + + + {t('extension.settings.password.enter.title')} + + + {error && ( + + {error} + + )} + + + + ) +} diff --git a/apps/extension/src/app/features/settings/password/EnterPasswordModal.tsx b/apps/extension/src/app/features/settings/password/EnterPasswordModal.tsx new file mode 100644 index 00000000000..de9c21fbf3a --- /dev/null +++ b/apps/extension/src/app/features/settings/password/EnterPasswordModal.tsx @@ -0,0 +1,69 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { PasswordInput } from 'src/app/components/PasswordInput' +import { Button, Flex, Square, Text, inputStyles, useSporeColors } from 'ui/src' +import { Lock } from 'ui/src/components/icons' +import { BottomSheetModal } from 'uniswap/src/components/modals/BottomSheetModal' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { Keyring } from 'wallet/src/features/wallet/Keyring/Keyring' + +export function EnterPasswordModal({ onNext, onClose }: { onNext: () => void; onClose: () => void }): JSX.Element { + const { t } = useTranslation() + const colors = useSporeColors() + + const [password, setPassword] = useState('') + const [showPasswordError, setShowPasswordError] = useState(false) + const [hideInput, setHideInput] = useState(true) + + const onChangeText = (text: string): void => { + setPassword(text) + setShowPasswordError(false) + } + + const checkPassword = async (): Promise => { + const success = await Keyring.checkPassword(password) + if (!success) { + setShowPasswordError(true) + return + } + onNext() + } + + return ( + + + + + + + {t('settings.setting.recoveryPhrase.password.title')} + + + + {showPasswordError ? t('setting.recoveryPhrase.remove.password.error') : ''} + + + + + ) +} diff --git a/apps/extension/src/app/features/settings/password/SettingsChangePasswordScreen.tsx b/apps/extension/src/app/features/settings/password/SettingsChangePasswordScreen.tsx new file mode 100644 index 00000000000..67ca27a0306 --- /dev/null +++ b/apps/extension/src/app/features/settings/password/SettingsChangePasswordScreen.tsx @@ -0,0 +1,34 @@ +import { t } from 'i18next' +import { useState } from 'react' +import { ScreenHeader } from 'src/app/components/layout/ScreenHeader' +import { ChangePasswordForm } from 'src/app/features/settings/password/ChangePasswordForm' +import { EnterPasswordForm } from 'src/app/features/settings/password/EnterPasswordForm' +import { useExtensionNavigation } from 'src/app/navigation/utils' +import { Flex } from 'ui/src' + +enum Step { + EnterPassword, + ChangePassword, +} + +export function SettingsChangePasswordScreen(): JSX.Element { + const [currentStep, setCurrentStep] = useState(Step.EnterPassword) + const { navigateBack } = useExtensionNavigation() + + let formContent + switch (currentStep) { + case Step.EnterPassword: + formContent = setCurrentStep(Step.ChangePassword)} /> + break + case Step.ChangePassword: + formContent = navigateBack()} /> + break + } + + return ( + + + {formContent} + + ) +} diff --git a/apps/extension/src/app/features/swap/SwapFlowScreen.tsx b/apps/extension/src/app/features/swap/SwapFlowScreen.tsx new file mode 100644 index 00000000000..a1cee3a6d50 --- /dev/null +++ b/apps/extension/src/app/features/swap/SwapFlowScreen.tsx @@ -0,0 +1,16 @@ +import { useExtensionNavigation } from 'src/app/navigation/utils' +import { Flex } from 'ui/src' +import { SwapFlow } from 'wallet/src/features/transactions/swap/SwapFlow' +import { useSwapPrefilledState } from 'wallet/src/features/transactions/swap/hooks/useSwapPrefilledState' + +export function SwapFlowScreen(): JSX.Element { + const { navigateBack, locationState } = useExtensionNavigation() + + const swapPrefilledState = useSwapPrefilledState(locationState?.initialTransactionState) + + return ( + + + + ) +} diff --git a/apps/extension/src/app/features/transfer/SendFormScreen/AmountSelector.tsx b/apps/extension/src/app/features/transfer/SendFormScreen/AmountSelector.tsx new file mode 100644 index 00000000000..f1b1bef06f8 --- /dev/null +++ b/apps/extension/src/app/features/transfer/SendFormScreen/AmountSelector.tsx @@ -0,0 +1,3 @@ +export function AmountSelector(): JSX.Element { + return <>Amount Input + Quick Chips +} diff --git a/apps/extension/src/app/features/transfer/SendFormScreen/GasFeeRow.tsx b/apps/extension/src/app/features/transfer/SendFormScreen/GasFeeRow.tsx new file mode 100644 index 00000000000..a885d265afe --- /dev/null +++ b/apps/extension/src/app/features/transfer/SendFormScreen/GasFeeRow.tsx @@ -0,0 +1,55 @@ +import { t } from 'i18next' +import { FadeIn } from 'react-native-reanimated' +import { Flex, SpinningLoader, Text } from 'ui/src' +import { Gas } from 'ui/src/components/icons' +import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' +import { iconSizes } from 'ui/src/theme' +import { WalletChainId } from 'uniswap/src/types/chains' +import { NumberType } from 'utilities/src/format/types' +import { useUSDValue } from 'wallet/src/features/gas/hooks' +import { GasFeeResult } from 'wallet/src/features/gas/types' +import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' +import { NetworkFeeWarning } from 'wallet/src/features/transactions/swap/modals/NetworkFeeWarning' + +type GasFeeRowProps = { + gasFee: GasFeeResult + chainId: WalletChainId +} + +export function GasFeeRow({ gasFee, chainId }: GasFeeRowProps): JSX.Element | null { + const { convertFiatAmountFormatted } = useLocalizationContext() + + const gasFeeUSD = useUSDValue(chainId, gasFee.value ?? undefined) + const gasFeeFormatted = convertFiatAmountFormatted(gasFeeUSD, NumberType.FiatTokenPrice) + + if (!gasFeeUSD) { + return null + } + + return ( + + + {t('send.gas.networkCost.title')} + + {gasFee.loading ? ( + + ) : gasFee.error ? ( + + {t('send.gas.error.title')} + + ) : ( + + + {gasFeeFormatted} + + + + } + /> + )} + + ) +} diff --git a/apps/extension/src/app/features/transfer/SendFormScreen/RecipientPanel.tsx b/apps/extension/src/app/features/transfer/SendFormScreen/RecipientPanel.tsx new file mode 100644 index 00000000000..f486c1a0ee7 --- /dev/null +++ b/apps/extension/src/app/features/transfer/SendFormScreen/RecipientPanel.tsx @@ -0,0 +1,106 @@ +import { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useTransferContext } from 'src/app/features/transfer/TransferContext' +import { Flex, Separator, Text, TouchableArea } from 'ui/src' +import { RotatableChevron, WalletFilled } from 'ui/src/components/icons' +import { iconSizes, spacing } from 'ui/src/theme' +import { RecipientList } from 'wallet/src/components/RecipientSearch/RecipientList' +import { useFilteredRecipientSections } from 'wallet/src/components/RecipientSearch/hooks' +import { AddressDisplay } from 'wallet/src/components/accounts/AddressDisplay' +import { SearchTextInput } from 'wallet/src/features/search/SearchTextInput' +import { selectRecipient } from 'wallet/src/features/transactions/transactionState/transactionState' +import { + useOnToggleShowRecipientSelector, + useSetShowRecipientSelector, +} from 'wallet/src/features/transactions/transfer/hooks/useOnToggleShowRecipientSelector' + +export function RecipientPanel(): JSX.Element { + const { t } = useTranslation() + + const [pattern, setPattern] = useState('') + const { recipient, dispatch, showRecipientSelector } = useTransferContext() + const onToggleShowRecipientSelector = useOnToggleShowRecipientSelector(dispatch) + const setShowRecipientSelector = useSetShowRecipientSelector(dispatch) + const sections = useFilteredRecipientSections(pattern) + + const onSelectRecipient = useCallback( + (newRecipient: string) => { + dispatch(selectRecipient({ recipient: newRecipient })) + setShowRecipientSelector(false) + }, + [dispatch, setShowRecipientSelector], + ) + + const onClose = (): void => { + setShowRecipientSelector(false) + } + + const noPatternOrFavorites = !pattern && sections.length === 0 + + return showRecipientSelector || !recipient ? ( + + + + {t('common.text.recipient')} + + + + setShowRecipientSelector(true)} + /> + + {showRecipientSelector && ( + + + + )} + + {showRecipientSelector && } + + {showRecipientSelector && + (noPatternOrFavorites ? ( + + + + {t('send.recipientSelect.search.empty.title')} + + {t('send.recipientSelect.search.empty.message')} + + + + ) : !sections.length ? ( + + {t('send.search.empty.title')} + + {t('send.search.empty.subtitle')} + + + ) : ( + // Show either suggested recipients or filtered sections based on query + + ))} + + ) : ( + + + + + + + ) +} diff --git a/apps/extension/src/app/features/transfer/SendFormScreen/ReviewButton.tsx b/apps/extension/src/app/features/transfer/SendFormScreen/ReviewButton.tsx new file mode 100644 index 00000000000..e609f9f9779 --- /dev/null +++ b/apps/extension/src/app/features/transfer/SendFormScreen/ReviewButton.tsx @@ -0,0 +1,52 @@ +import { useTranslation } from 'react-i18next' +import { useTransferContext } from 'src/app/features/transfer/TransferContext' +import { Button, Flex, Text, isWeb } from 'ui/src' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { ElementName } from 'uniswap/src/features/telemetry/constants' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' +import { NativeCurrency } from 'wallet/src/features/tokens/NativeCurrency' +import { WarningLabel } from 'wallet/src/features/transactions/WarningModal/types' + +type ReviewButtonProps = { + onPress: () => void + disabled?: boolean +} + +export function ReviewButton({ onPress, disabled }: ReviewButtonProps): JSX.Element { + const { t } = useTranslation() + + const { + warnings, + derivedTransferInfo: { chainId }, + } = useTransferContext() + + const nativeCurrencySymbol = NativeCurrency.onChain(chainId).symbol + + const insufficientGasFunds = warnings.warnings.some((warning) => warning.type === WarningLabel.InsufficientGasFunds) + + const disableReviewButton = !!warnings.blockingWarning || disabled + + const buttonText = insufficientGasFunds + ? t('send.warning.insufficientFunds.title', { + currencySymbol: nativeCurrencySymbol, + }) + : t('common.button.review') + + return ( + + + + + + ) +} diff --git a/apps/extension/src/app/features/transfer/SendFormScreen/SendFormScreen.tsx b/apps/extension/src/app/features/transfer/SendFormScreen/SendFormScreen.tsx new file mode 100644 index 00000000000..0a93dd7a5cd --- /dev/null +++ b/apps/extension/src/app/features/transfer/SendFormScreen/SendFormScreen.tsx @@ -0,0 +1,186 @@ +import { useCallback, useState } from 'react' +import { GasFeeRow } from 'src/app/features/transfer/SendFormScreen/GasFeeRow' +import { RecipientPanel } from 'src/app/features/transfer/SendFormScreen/RecipientPanel' +import { ReviewButton } from 'src/app/features/transfer/SendFormScreen/ReviewButton' +import { SendReviewScreen } from 'src/app/features/transfer/SendReviewScreen/SendReviewScreen' +import { TransferScreen, useTransferContext } from 'src/app/features/transfer/TransferContext' +import { Flex, Separator, useSporeColors } from 'ui/src' +import { BottomSheetModal } from 'uniswap/src/components/modals/BottomSheetModal' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { ModalName, SectionName } from 'uniswap/src/features/telemetry/constants' +import { InsufficientNativeTokenWarning } from 'wallet/src/features/transactions/InsufficientNativeTokenWarning/InsufficientNativeTokenWarning' +import { useTokenFormActionHandlers } from 'wallet/src/features/transactions/hooks/useTokenFormActionHandlers' +import { useTokenSelectorActionHandlers } from 'wallet/src/features/transactions/hooks/useTokenSelectorActionHandlers' +import { useUSDCValue } from 'wallet/src/features/transactions/swap/trade/hooks/useUSDCPrice' +import { useUSDTokenUpdater } from 'wallet/src/features/transactions/swap/trade/hooks/useUSDTokenUpdater' +import { transactionStateActions } from 'wallet/src/features/transactions/transactionState/transactionState' +import { CurrencyField } from 'wallet/src/features/transactions/transactionState/types' +import { TokenSelectorPanel } from 'wallet/src/features/transactions/transfer/TokenSelectorPanel' +import { TransferAmountInput } from 'wallet/src/features/transactions/transfer/TransferAmountInput' +import { TransferFormSpeedbumps } from 'wallet/src/features/transactions/transfer/TransferFormWarnings' +import { useShowSendNetworkNotification } from 'wallet/src/features/transactions/transfer/hooks/useShowSendNetworkNotification' +import { TokenSelectorFlow, TransferSpeedbump } from 'wallet/src/features/transactions/transfer/types' +import { createTransactionId } from 'wallet/src/features/transactions/utils' +import { BlockedAddressWarning } from 'wallet/src/features/trm/BlockedAddressWarning' +import { useIsBlocked, useIsBlockedActiveAddress } from 'wallet/src/features/trm/hooks' + +export function SendFormScreen(): JSX.Element { + const colors = useSporeColors() + const { + dispatch, + derivedTransferInfo, + selectingCurrencyField, + exactAmountToken, + exactAmountFiat, + isFiatInput, + warnings, + gasFee, + showRecipientSelector, + screen, + setScreen, + recipient, + } = useTransferContext() + + const { currencyInInfo, currencyBalances, currencyAmounts, chainId } = derivedTransferInfo + + useShowSendNetworkNotification({ chainId: currencyInInfo?.currency.chainId }) + + const { onSetExactAmount, onSetMax, onToggleFiatInput } = useTokenFormActionHandlers(dispatch) + const { onSelectCurrency, onHideTokenSelector, onShowTokenSelector } = useTokenSelectorActionHandlers( + dispatch, + TokenSelectorFlow.Transfer, + ) + + const currencyUSDValue = useUSDCValue(currencyAmounts[CurrencyField.INPUT]) + + // Sync fiat and token amounts + useUSDTokenUpdater(dispatch, Boolean(isFiatInput), exactAmountToken, exactAmountFiat ?? '', currencyInInfo?.currency) + + const exactValue = isFiatInput ? exactAmountFiat : exactAmountToken + + const showTokenSelector = selectingCurrencyField === CurrencyField.INPUT + + // warnings + const [showSpeedbumpModal, setShowSpeedbumpModal] = useState(false) + const [transferSpeedbump, setTransferSpeedbump] = useState({ + loading: true, + hasWarning: false, + }) + + // blocked addresses + const { isBlocked: isActiveBlocked, isBlockedLoading: isActiveBlockedLoading } = useIsBlockedActiveAddress() + const { isBlocked: isRecipientBlocked, isBlockedLoading: isRecipientBlockedLoading } = useIsBlocked(recipient) + const isBlocked = isActiveBlocked || isRecipientBlocked + const isBlockedLoading = isActiveBlockedLoading || isRecipientBlockedLoading + + const onShowReviewScreen = useCallback(() => { + setShowSpeedbumpModal(false) + const txId = createTransactionId() + dispatch(transactionStateActions.setTxId(txId)) + setScreen(TransferScreen.SendReview) + }, [dispatch, setScreen]) + + const onPressReview = useCallback(() => { + if (transferSpeedbump.hasWarning) { + setShowSpeedbumpModal(true) + } else { + onShowReviewScreen() + } + }, [onShowReviewScreen, transferSpeedbump.hasWarning]) + + const inputShadowProps = { + shadowColor: colors.surface3.val, + shadowRadius: 10, + shadowOpacity: 0.04, + zIndex: 1, + } + + return ( + + {screen === TransferScreen.SendReview && ( + + + + )} + + + + onShowTokenSelector(CurrencyField.INPUT)} + /> + {!showTokenSelector && ( + <> + + + + )} + + {!showTokenSelector && ( + <> + + + + {!showRecipientSelector && ( + <> + {isBlocked && ( + + )} + + + + + )} + + )} + + + ) +} diff --git a/apps/extension/src/app/features/transfer/SendReviewScreen/SendDetails.tsx b/apps/extension/src/app/features/transfer/SendReviewScreen/SendDetails.tsx new file mode 100644 index 00000000000..00aa11f426b --- /dev/null +++ b/apps/extension/src/app/features/transfer/SendReviewScreen/SendDetails.tsx @@ -0,0 +1,198 @@ +import { providers } from 'ethers' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Button, Flex, Separator, Text, useSporeColors } from 'ui/src' +import { useDeviceDimensions } from 'ui/src/hooks/useDeviceDimensions' +import { iconSizes } from 'ui/src/theme' +import { CurrencyLogo } from 'uniswap/src/components/CurrencyLogo/CurrencyLogo' +import { ElementName, ModalName } from 'uniswap/src/features/telemetry/constants' +import { NumberType } from 'utilities/src/format/types' +import { AccountIcon } from 'wallet/src/components/accounts/AccountIcon' +import { AddressDisplay } from 'wallet/src/components/accounts/AddressDisplay' +import { Arrow } from 'wallet/src/components/icons/Arrow' +import { WarningModal } from 'wallet/src/components/modals/WarningModal/WarningModal' +import { NFTTransfer } from 'wallet/src/components/nfts/NFTTransfer' +import { useAppFiatCurrencyInfo } from 'wallet/src/features/fiatCurrency/hooks' +import { GasFeeResult } from 'wallet/src/features/gas/types' +import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' +import { TransactionDetails } from 'wallet/src/features/transactions/TransactionDetails/TransactionDetails' +import { WarningSeverity } from 'wallet/src/features/transactions/WarningModal/types' +import { ParsedWarnings } from 'wallet/src/features/transactions/hooks/useParsedTransactionWarnings' +import { useUSDCValue } from 'wallet/src/features/transactions/swap/trade/hooks/useUSDCPrice' +import { CurrencyField } from 'wallet/src/features/transactions/transactionState/types' +import { DerivedTransferInfo } from 'wallet/src/features/transactions/transfer/types' +import { AccountType } from 'wallet/src/features/wallet/accounts/types' +import { useActiveAccountWithThrow, useAvatar } from 'wallet/src/features/wallet/hooks' + +interface TransferFormProps { + derivedTransferInfo: DerivedTransferInfo + txRequest?: providers.TransactionRequest + gasFee: GasFeeResult + onReviewSubmit: () => void + warnings: ParsedWarnings +} + +/** + * TODO: MOB-2563 https://linear.app/uniswap/issue/MOB-2563/consolidate-all-transfer-logic-ext-mob + * Re-use this component when implemting shared review UI on mobile, and move to shared package. + */ +export function SendDetails({ + derivedTransferInfo, + gasFee, + onReviewSubmit, + txRequest, + warnings, +}: TransferFormProps): JSX.Element | null { + const { t } = useTranslation() + const { fullHeight } = useDeviceDimensions() + const colors = useSporeColors() + + const { formatCurrencyAmount, formatNumberOrString, convertFiatAmountFormatted } = useLocalizationContext() + + const account = useActiveAccountWithThrow() + + const [showWarningModal, setShowWarningModal] = useState(false) + const currency = useAppFiatCurrencyInfo() + + const onShowWarning = (): void => { + setShowWarningModal(true) + } + + const onCloseWarning = (): void => { + setShowWarningModal(false) + } + + const { + currencyAmounts, + recipient, + isFiatInput = false, + currencyInInfo, + nftIn, + chainId, + exactAmountFiat, + } = derivedTransferInfo + + const { avatar } = useAvatar(recipient) + + const inputCurrencyUSDValue = useUSDCValue(currencyAmounts[CurrencyField.INPUT]) + + const { blockingWarning } = warnings + + const actionButtonDisabled = + !!blockingWarning || !gasFee.value || !!gasFee.error || !txRequest || account.type === AccountType.Readonly + + const actionButtonProps = { + disabled: actionButtonDisabled, + label: t('send.review.summary.button.title'), + name: ElementName.Send, + onPress: onReviewSubmit, + } + + const transferWarning = warnings.warnings.find((warning) => warning.severity >= WarningSeverity.Medium) + + const formattedCurrencyAmount = formatCurrencyAmount({ + value: currencyAmounts[CurrencyField.INPUT], + type: NumberType.TokenTx, + }) + const formattedAmountIn = isFiatInput + ? formatNumberOrString({ + value: exactAmountFiat, + type: NumberType.FiatTokenQuantity, + currencyCode: currency.code, + }) + : formattedCurrencyAmount + + const formattedInputFiatValue = convertFiatAmountFormatted( + inputCurrencyUSDValue?.toExact(), + NumberType.FiatTokenQuantity, + ) + + if (!recipient) { + throw new Error('Invalid render of SendDetails with no recipient') + } + + return ( + <> + {showWarningModal && transferWarning?.title && ( + + )} + + {currencyInInfo ? ( + + + + + {formattedAmountIn} {!isFiatInput ? currencyInInfo.currency.symbol : ''} + + + {isFiatInput ? ( + + {formattedCurrencyAmount} {currencyInInfo.currency.symbol} + + ) : ( + inputCurrencyUSDValue && ( + + {formattedInputFiatValue} + + ) + )} + + + + ) : ( + nftIn && ( + + + + ) + )} + + + + {recipient && ( + + + + + )} + + + + + + + + ) +} diff --git a/apps/extension/src/app/features/transfer/SendReviewScreen/SendReviewScreen.tsx b/apps/extension/src/app/features/transfer/SendReviewScreen/SendReviewScreen.tsx new file mode 100644 index 00000000000..7fe565e97bb --- /dev/null +++ b/apps/extension/src/app/features/transfer/SendReviewScreen/SendReviewScreen.tsx @@ -0,0 +1,107 @@ +import { useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { useDispatch } from 'react-redux' +import { SendDetails } from 'src/app/features/transfer/SendReviewScreen/SendDetails' +import { TransferScreen, useTransferContext } from 'src/app/features/transfer/TransferContext' +import { Flex, Text, TouchableArea } from 'ui/src' +import { X } from 'ui/src/components/icons' +import Trace from 'uniswap/src/features/telemetry/Trace' +import { SectionName } from 'uniswap/src/features/telemetry/constants' +import { currencyAddress } from 'uniswap/src/utils/currencyId' +import { logger } from 'utilities/src/logger/logger' +import { useWalletNavigation } from 'wallet/src/contexts/WalletNavigationContext' +import { pushNotification } from 'wallet/src/features/notifications/slice' +import { AppNotificationType } from 'wallet/src/features/notifications/types' +import { CurrencyField } from 'wallet/src/features/transactions/transactionState/types' +import { + useTransferERC20Callback, + useTransferNFTCallback, +} from 'wallet/src/features/transactions/transfer/hooks/useTransferCallback' + +export function SendReviewScreen(): JSX.Element { + const dispatch = useDispatch() + const { t } = useTranslation() + + const { navigateToAccountActivityList } = useWalletNavigation() + + const { derivedTransferInfo, warnings, txRequest, gasFee, setScreen } = useTransferContext() + const { txId, chainId, recipient, currencyInInfo, currencyAmounts, nftIn } = derivedTransferInfo + + const triggerTransferPendingNotification = useCallback(() => { + if (!currencyInInfo) { + // This should never happen. Just keeping TS happy. + logger.error(new Error('Missing `currencyInInfo` when triggering transfer pending notification'), { + tags: { file: 'SendReviewScreen.tsx', function: 'triggerTransferPendingNotification' }, + }) + } else { + dispatch( + pushNotification({ + type: AppNotificationType.TransferCurrencyPending, + currencyInfo: currencyInInfo, + }), + ) + } + }, [currencyInInfo, dispatch]) + + const onNext = useCallback((): void => { + triggerTransferPendingNotification() + navigateToAccountActivityList() + }, [navigateToAccountActivityList, triggerTransferPendingNotification]) + + const transferERC20Callback = useTransferERC20Callback( + txId, + chainId, + recipient, + currencyInInfo ? currencyAddress(currencyInInfo.currency) : undefined, + currencyAmounts[CurrencyField.INPUT]?.quotient.toString(), + txRequest, + onNext, + ) + + const transferNFTCallback = useTransferNFTCallback( + txId, + chainId, + recipient, + nftIn?.nftContract?.address, + nftIn?.tokenId, + txRequest, + onNext, + ) + + const onTransfer = (): void => { + nftIn ? transferNFTCallback?.() : transferERC20Callback?.() + } + + const onPrev = (): void => { + setScreen(TransferScreen.SendForm) + } + + return ( + + + + {t('send.review.modal.title')} + + + + + + + + ) +} diff --git a/apps/extension/src/app/features/transfer/TransferContext.tsx b/apps/extension/src/app/features/transfer/TransferContext.tsx new file mode 100644 index 00000000000..a674001db0d --- /dev/null +++ b/apps/extension/src/app/features/transfer/TransferContext.tsx @@ -0,0 +1,114 @@ +import { TransactionRequest } from '@ethersproject/providers' +import { providers } from 'ethers' +import React, { createContext, ReactNode, useContext, useMemo, useReducer, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { AnyAction } from 'redux' +import { useTransactionGasFee } from 'wallet/src/features/gas/hooks' +import { GasFeeResult, GasSpeed } from 'wallet/src/features/gas/types' +import { + ParsedWarnings, + useParsedSendWarnings, +} from 'wallet/src/features/transactions/hooks/useParsedTransactionWarnings' +import { useTransactionGasWarning } from 'wallet/src/features/transactions/hooks/useTransactionGasWarning' +import { + INITIAL_TRANSACTION_STATE, + transactionStateReducer, +} from 'wallet/src/features/transactions/transactionState/transactionState' +import { TransactionState } from 'wallet/src/features/transactions/transactionState/types' +import { useDerivedTransferInfo } from 'wallet/src/features/transactions/transfer/hooks/useDerivedTransferInfo' +import { useTransferTransactionRequest } from 'wallet/src/features/transactions/transfer/hooks/useTransferTransactionRequest' +import { useTransferWarnings } from 'wallet/src/features/transactions/transfer/hooks/useTransferWarnings' +import { WarningAction } from 'wallet/src/features/transactions/WarningModal/types' + +export enum TransferScreen { + SendForm, + SendReview, +} + +export enum TransferEntryType { + Fiat, + Crypto, +} + +type TransferContextState = { + screen: TransferScreen + setScreen: (newScreen: TransferScreen) => void + dispatch: React.Dispatch + derivedTransferInfo: ReturnType + gasFee: GasFeeResult + warnings: ParsedWarnings + txRequest: TransactionRequest | undefined +} & TransactionState + +export const TransferContext = createContext(undefined) + +export function TransferContextProvider({ + prefilledTransactionState, + children, +}: { + prefilledTransactionState?: TransactionState + children: ReactNode +}): JSX.Element { + const { t } = useTranslation() + + // state and reducers + const [transferFormState, dispatch] = useReducer(transactionStateReducer, { + ...(prefilledTransactionState ?? INITIAL_TRANSACTION_STATE), + showRecipientSelector: false, + }) + const [screen, setScreen] = useState(TransferScreen.SendForm) + + // derived info based on transfer state + const derivedTransferInfo = useDerivedTransferInfo(transferFormState) + + const warnings = useTransferWarnings(t, derivedTransferInfo) + + const txRequest = useTransferTransactionRequest(derivedTransferInfo) + + const gasFee = useTransactionGasFee( + txRequest, + GasSpeed.Urgent, + warnings.some((warning) => warning.action === WarningAction.DisableReview), + ) + + const txRequestWithGasSettings = useMemo( + (): providers.TransactionRequest => ({ ...txRequest, ...gasFee.params }), + [gasFee.params, txRequest], + ) + + const gasWarning = useTransactionGasWarning({ + derivedInfo: derivedTransferInfo, + gasFee: gasFee?.value, + }) + + const allSendWarnings = useMemo(() => { + return !gasWarning ? warnings : [...warnings, gasWarning] + }, [warnings, gasWarning]) + + const parsedSendWarnings = useParsedSendWarnings(allSendWarnings) + + const state: TransferContextState = useMemo(() => { + return { + derivedTransferInfo, + screen, + setScreen, + dispatch, + gasFee, + warnings: parsedSendWarnings, + txRequest: txRequestWithGasSettings, + ...transferFormState, + } + }, [derivedTransferInfo, gasFee, parsedSendWarnings, screen, transferFormState, txRequestWithGasSettings]) + + return {children} +} + +export const useTransferContext = (): TransferContextState => { + const transferContext = useContext(TransferContext) + + if (transferContext === undefined) { + throw new Error('`useTransferContext` must be used inside of `TransferContextProvider`') + } + + return transferContext +} diff --git a/apps/extension/src/app/features/transfer/TransferFlowScreen.tsx b/apps/extension/src/app/features/transfer/TransferFlowScreen.tsx new file mode 100644 index 00000000000..6a8a3daba90 --- /dev/null +++ b/apps/extension/src/app/features/transfer/TransferFlowScreen.tsx @@ -0,0 +1,26 @@ +import { useTranslation } from 'react-i18next' +import { ScreenHeader } from 'src/app/components/layout/ScreenHeader' +import { SCREEN_ITEM_HORIZONTAL_PAD } from 'src/app/constants' +import { SendFormScreen } from 'src/app/features/transfer/SendFormScreen/SendFormScreen' +import { TransferContextProvider } from 'src/app/features/transfer/TransferContext' +import { useExtensionNavigation } from 'src/app/navigation/utils' +import { Flex } from 'ui/src' +import { X } from 'ui/src/components/icons' + +export function TransferFlowScreen(): JSX.Element { + const { t } = useTranslation() + const { navigateBack, locationState } = useExtensionNavigation() + + return ( + + + + + + + + + + + ) +} diff --git a/apps/extension/src/app/features/warnings/StorageWarningModal.tsx b/apps/extension/src/app/features/warnings/StorageWarningModal.tsx new file mode 100644 index 00000000000..dc7c3f5e642 --- /dev/null +++ b/apps/extension/src/app/features/warnings/StorageWarningModal.tsx @@ -0,0 +1,42 @@ +import { useTranslation } from 'react-i18next' +import { ONBOARDING_CONTENT_WIDTH } from 'src/app/features/onboarding/utils' +import { useCheckLowStorage } from 'src/app/features/warnings/useCheckLowStorage' +import { AppRoutes, SettingsRoutes } from 'src/app/navigation/constants' +import { useExtensionNavigation } from 'src/app/navigation/utils' +import { spacing } from 'ui/src/theme' +import { ModalName } from 'uniswap/src/features/telemetry/constants' +import { WarningModal } from 'wallet/src/components/modals/WarningModal/WarningModal' +import { WarningSeverity } from 'wallet/src/features/transactions/WarningModal/types' + +export type StorageWarningModalProps = { + isOnboarding: boolean +} +export function StorageWarningModal({ isOnboarding }: StorageWarningModalProps): JSX.Element | null { + const { t } = useTranslation() + const { navigateTo } = useExtensionNavigation() + const { showStorageWarning, onStorageWarningClose } = useCheckLowStorage({ isOnboarding }) + + if (!showStorageWarning) { + return null + } + return ( + { + onStorageWarningClose() + navigateTo(`${AppRoutes.Settings}/${SettingsRoutes.ViewRecoveryPhrase}`) + } + } + /> + ) +} diff --git a/apps/extension/src/app/features/warnings/useCheckLowStorage.ts b/apps/extension/src/app/features/warnings/useCheckLowStorage.ts new file mode 100644 index 00000000000..3207d917f53 --- /dev/null +++ b/apps/extension/src/app/features/warnings/useCheckLowStorage.ts @@ -0,0 +1,48 @@ +import { useCallback, useEffect, useState } from 'react' +import { GlobalErrorEvent } from 'src/app/events/constants' +import { globalEventEmitter } from 'src/app/events/global' +import { logger } from 'utilities/src/logger/logger' + +export const REMAINING_STORAGE_THRESHOLD_BYTES = 500000 // 500KB + +export function useCheckLowStorage({ isOnboarding }: { isOnboarding: boolean }): { + showStorageWarning: boolean + onStorageWarningClose: () => void +} { + const [hasShownWarning, setHasShownWarning] = useState(false) + const [showStorageWarning, setShowStorageWarning] = useState(false) + + const onStorageWarningClose = useCallback(() => setShowStorageWarning(false), []) + const triggerStorageWarning = useCallback((): void => { + if (!hasShownWarning) { + setShowStorageWarning(true) + setHasShownWarning(true) + } + }, [hasShownWarning]) + + useEffect(() => { + if (!isOnboarding) { + navigator.storage + .estimate() + .then(({ quota }) => { + if (quota && quota < REMAINING_STORAGE_THRESHOLD_BYTES) { + triggerStorageWarning() + logger.info('useCheckLowStorage.ts', 'useCheckLowStorage', 'Low storage warning shown') + } + }) + .catch(() => {}) + } + }, [isOnboarding, triggerStorageWarning]) + + useEffect(() => { + const listener = (): void => { + triggerStorageWarning() + } + globalEventEmitter.addListener(GlobalErrorEvent.ReduxStorageExceeded, listener) + return () => { + globalEventEmitter.removeListener(GlobalErrorEvent.ReduxStorageExceeded, listener) + } + }, [hasShownWarning, triggerStorageWarning]) + + return { showStorageWarning, onStorageWarningClose } +} diff --git a/apps/extension/src/app/hooks/useIsWalletUnlocked.ts b/apps/extension/src/app/hooks/useIsWalletUnlocked.ts new file mode 100644 index 00000000000..15dc368a865 --- /dev/null +++ b/apps/extension/src/app/hooks/useIsWalletUnlocked.ts @@ -0,0 +1,59 @@ +import { useCallback, useEffect, useState } from 'react' +import { logger } from 'utilities/src/logger/logger' +import { useAsyncData } from 'utilities/src/react/hooks' +import { Keyring } from 'wallet/src/features/wallet/Keyring/Keyring' +import { ENCRYPTION_KEY_STORAGE_KEY, PersistedStorage } from 'wallet/src/utils/persistedStorage' + +/** + * In order to speed up the initial load of the app and avoid a half a second loading spinner every time the sidebar opens, + * we will first do a quick light check to see if the wallet *might* be unlocked by simply checking if the encryption key + * exists in local storage, but without actually verifying that this key is valid. + * + * After the React app fully loads, we will then do a more thorough check to see if the wallet is actually unlocked. + */ + +// exported to be used in saga's +export let isWalletUnlocked: boolean | null = null + +const sessionStorage = new PersistedStorage('session') + +sessionStorage + .getItem(ENCRYPTION_KEY_STORAGE_KEY) + .then((val) => { + isWalletUnlocked = val !== undefined + }) + .catch((err) => { + logger.error(err, { + tags: { + file: 'useIsWalletUnlocked.ts', + function: 'sessionStorage.getItem', + }, + }) + }) + +export function useIsWalletUnlocked(): boolean | null { + const [isUnlocked, setIsUnlocked] = useState(isWalletUnlocked) + + const checkWalletStatus = useCallback(async () => { + isWalletUnlocked = await Keyring.isUnlocked() + setIsUnlocked(isWalletUnlocked) + }, []) + + useEffect(() => { + const listener: Parameters[0] = async (changes, namespace) => { + if (namespace === 'session' && changes[ENCRYPTION_KEY_STORAGE_KEY]) { + await checkWalletStatus() + } + } + + chrome.storage.onChanged.addListener(listener) + + return () => { + chrome.storage.onChanged.removeListener(listener) + } + }, [checkWalletStatus]) + + useAsyncData(checkWalletStatus) + + return isUnlocked +} diff --git a/apps/extension/src/app/hooks/useOnCopyToClipboard.tsx b/apps/extension/src/app/hooks/useOnCopyToClipboard.tsx new file mode 100644 index 00000000000..dd8f43ed0d4 --- /dev/null +++ b/apps/extension/src/app/hooks/useOnCopyToClipboard.tsx @@ -0,0 +1,39 @@ +import { useCallback } from 'react' +import { pushNotification } from 'wallet/src/features/notifications/slice' +import { AppNotificationType, CopyNotificationType } from 'wallet/src/features/notifications/types' +import { useAppDispatch } from 'wallet/src/state' + +export function useCopyToClipboard(): ({ + textToCopy, + copyType, +}: { + textToCopy: string + copyType: CopyNotificationType +}) => Promise { + const dispatch = useAppDispatch() + + const copyToClipboard = useCallback( + async ({ textToCopy, copyType }: { textToCopy: string; copyType: CopyNotificationType }) => { + try { + await navigator.clipboard.writeText(textToCopy) + + dispatch( + pushNotification({ + type: AppNotificationType.Copied, + copyType, + }), + ) + } catch (e) { + dispatch( + pushNotification({ + type: AppNotificationType.CopyFailed, + copyType, + }), + ) + } + }, + [dispatch], + ) + + return copyToClipboard +} diff --git a/apps/extension/src/app/hooks/useOpeningKeyboardShortCut.test.ts b/apps/extension/src/app/hooks/useOpeningKeyboardShortCut.test.ts new file mode 100644 index 00000000000..96801e62898 --- /dev/null +++ b/apps/extension/src/app/hooks/useOpeningKeyboardShortCut.test.ts @@ -0,0 +1,154 @@ +import { State, useOpeningKeyboardShortCut } from 'src/app/hooks/useOpeningKeyboardShortCut' +import * as isAppleDeviceDep from 'src/app/utils/isAppleDevice' +import { act, renderHook } from 'src/test/test-utils' + +jest.mock('src/app/utils/isAppleDevice', () => ({ + isAppleDevice: jest.fn(), +})) + +const isAppleDevice = isAppleDeviceDep.isAppleDevice as jest.MockedFunction< + typeof isAppleDeviceDep.isAppleDevice +> + +describe('useOpeningKeyboardShortCut', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should initialize with the correct keys for an Apple device', () => { + isAppleDevice.mockReturnValue(true) + const { result } = renderHook(() => useOpeningKeyboardShortCut(false)) + + expect(result.current).toEqual([ + { + fontSize: 28, + px: '$spacing28', + title: 'Shift', + state: State.KeyUp, + }, + { + fontSize: 41, + px: '$spacing16', + title: 'Meta', + state: State.KeyUp, + }, + { + fontSize: 41, + px: '$spacing24', + title: 'U', + state: State.KeyUp, + }, + ]) + }) + + it('should initialize with the correct keys for a non-Apple device', () => { + isAppleDevice.mockReturnValue(false) + const { result } = renderHook(() => useOpeningKeyboardShortCut(false)) + + expect(result.current).toEqual([ + { + fontSize: 28, + px: '$spacing28', + title: 'Shift', + state: State.KeyUp, + }, + { + fontSize: 28, + px: '$spacing12', + title: 'Crtl', + state: State.KeyUp, + }, + { + fontSize: 41, + px: '$spacing24', + title: 'U', + state: State.KeyUp, + }, + ]) + }) + + it('should handle keyDown and keyUp events', () => { + isAppleDevice.mockReturnValue(false) + const { result } = renderHook(() => useOpeningKeyboardShortCut(false)) + + act(() => { + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Shift' })) + }) + + expect(result.current).toEqual([ + { + fontSize: 28, + px: '$spacing28', + title: 'Shift', + state: State.KeyDown, + }, + { + fontSize: 28, + px: '$spacing12', + title: 'Crtl', + state: State.KeyUp, + }, + { + fontSize: 41, + px: '$spacing24', + title: 'U', + state: State.KeyUp, + }, + ]) + + act(() => { + window.dispatchEvent(new KeyboardEvent('keyup', { key: 'Shift' })) + }) + + expect(result.current).toEqual([ + { + fontSize: 28, + px: '$spacing28', + title: 'Shift', + state: State.KeyUp, + }, + { + fontSize: 28, + px: '$spacing12', + title: 'Crtl', + state: State.KeyUp, + }, + { + fontSize: 41, + px: '$spacing24', + title: 'U', + state: State.KeyUp, + }, + ]) + }) + + it('should highlight keys when shortCutPressed is true', () => { + isAppleDevice.mockReturnValue(false) + const { result, rerender } = renderHook((props) => useOpeningKeyboardShortCut(props), { + initialProps: false, + }) + + rerender(true) + + expect(result.current).toEqual([ + { + fontSize: 28, + px: '$spacing28', + title: 'Shift', + state: State.Highlighted, + }, + { + fontSize: 28, + px: '$spacing12', + title: 'Crtl', + state: State.Highlighted, + }, + { + fontSize: 41, + px: '$spacing24', + title: 'U', + state: State.Highlighted, + }, + ]) + }) +}) diff --git a/apps/extension/src/app/hooks/useOpeningKeyboardShortCut.ts b/apps/extension/src/app/hooks/useOpeningKeyboardShortCut.ts new file mode 100644 index 00000000000..87433f2d9c0 --- /dev/null +++ b/apps/extension/src/app/hooks/useOpeningKeyboardShortCut.ts @@ -0,0 +1,77 @@ +import { useEffect, useReducer } from 'react' +import { KeyboardKeyProps } from 'src/app/features/onboarding/KeyboardKey' +import { isAppleDevice } from 'src/app/utils/isAppleDevice' + +const KEY_LONG_TEXT_FONT_SIZE = 28 +const KEY_SHORT_TEXT_FONT_SIZE = 41 + +// export for tests +export enum State { + KeyUp, + KeyDown, + Highlighted, +} + +type ReducerAction = { type: 'keyUp' | 'keyDown' | 'highlight'; key: string } | { type: 'highlight' } + +export const useOpeningKeyboardShortCut = (shortCutPressed: boolean): KeyboardKeyProps[] => { + const reducer = (state: KeyboardKeyProps[], action: ReducerAction): KeyboardKeyProps[] => { + switch (action.type) { + case 'keyDown': + return state.map((key) => (key.title.toLowerCase() === action.key ? { ...key, state: State.KeyDown } : key)) + case 'keyUp': + return state.map((key) => + key.title.toLowerCase() === action.key || + // after pressing Cmd+ keyUp event would only be fired for Cmd, this would "simulate" keyDown for letter + // context: https://github.com/electron/electron/issues/5188 + (action.key === 'meta' && key.title.length === 1) + ? { ...key, state: shortCutPressed ? State.Highlighted : State.KeyUp } + : key, + ) + case 'highlight': + return state.map((key) => ({ ...key, state: State.Highlighted })) + } + } + + const [keys, dispatch] = useReducer(reducer, [ + { + fontSize: KEY_LONG_TEXT_FONT_SIZE, + px: '$spacing28', + title: 'Shift', + state: State.KeyUp, + }, + isAppleDevice() + ? { + fontSize: KEY_SHORT_TEXT_FONT_SIZE, + px: '$spacing16', + title: 'Meta', + state: State.KeyUp, + } + : { + fontSize: KEY_LONG_TEXT_FONT_SIZE, + px: '$spacing12', + title: 'Crtl', + state: State.KeyUp, + }, + { fontSize: KEY_SHORT_TEXT_FONT_SIZE, px: '$spacing24', title: 'U', state: State.KeyUp }, + ]) + + useEffect(() => { + if (shortCutPressed) { + dispatch({ type: 'highlight' }) + } + }, [shortCutPressed]) + + useEffect(() => { + const keyDownHandler = (event: KeyboardEvent): void => dispatch({ type: 'keyDown', key: event.key.toLowerCase() }) + const keyUpHandler = (event: KeyboardEvent): void => dispatch({ type: 'keyUp', key: event.key.toLowerCase() }) + window.addEventListener('keydown', keyDownHandler) + window.addEventListener('keyup', keyUpHandler) + + return () => { + window.removeEventListener('keydown', keyDownHandler) + window.removeEventListener('keyup', keyUpHandler) + } + }, []) + return keys +} diff --git a/apps/extension/src/app/hooks/useOptimizedSearchParams.tsx b/apps/extension/src/app/hooks/useOptimizedSearchParams.tsx new file mode 100644 index 00000000000..083985c2466 --- /dev/null +++ b/apps/extension/src/app/hooks/useOptimizedSearchParams.tsx @@ -0,0 +1,30 @@ +import { useEffect, useState } from 'react' +import { createSearchParams } from 'react-router-dom' +import { getRouter } from 'src/app/navigation/state' +import { sleep } from 'utilities/src/time/timing' + +const getSearchParams = (): URLSearchParams => createSearchParams(new URLSearchParams(window.location.hash.slice(2))) + +/** + * It's just like useSearchParams but avoids re-rendering on every page navigation + */ + +export function useOptimizedSearchParams(): URLSearchParams { + const [searchParams, setSearchParams] = useState(getSearchParams) + + useEffect(() => { + return getRouter().subscribe(async () => { + // react-router-dom calls this before it actually updates the url bar :/ + await sleep(0) + setSearchParams((prev) => { + const next = getSearchParams() + if (prev.toString() !== next.toString()) { + return next + } + return prev + }) + }) + }, []) + + return searchParams +} diff --git a/apps/extension/src/app/hooks/useSagaStatus.ts b/apps/extension/src/app/hooks/useSagaStatus.ts new file mode 100644 index 00000000000..b72120875ec --- /dev/null +++ b/apps/extension/src/app/hooks/useSagaStatus.ts @@ -0,0 +1,39 @@ +import { useEffect } from 'react' +import { monitoredSagas } from 'src/app/saga' +import { useAppDispatch, useAppSelector } from 'src/store/store' +import { SagaState, SagaStatus } from 'wallet/src/utils/saga' + +// Convenience hook to get the status + error of an active saga +export function useSagaStatus(sagaName: string, onSuccess?: () => void, resetSagaOnSuccess = true): SagaState { + const dispatch = useAppDispatch() + const sagaState = useAppSelector((s): SagaState | undefined => s.saga[sagaName]) + if (!sagaState) { + throw new Error(`No saga state found, is sagaName valid? Name: ${sagaName}`) + } + + const saga = monitoredSagas[sagaName] + if (!saga) { + throw new Error(`No saga found, is sagaName valid? Name: ${sagaName}`) + } + + const { status, error } = sagaState + + useEffect(() => { + if (status === SagaStatus.Success) { + if (resetSagaOnSuccess) { + dispatch(saga.actions.reset()).catch(() => undefined) + } + onSuccess?.() + } + }, [saga, status, error, onSuccess, resetSagaOnSuccess, dispatch]) + + useEffect(() => { + return () => { + if (resetSagaOnSuccess) { + dispatch(saga.actions.reset()).catch(() => undefined) + } + } + }, [saga, resetSagaOnSuccess, dispatch]) + + return sagaState +} diff --git a/apps/extension/src/app/navigation/HideContentsWhenSidebarBecomesInactive.tsx b/apps/extension/src/app/navigation/HideContentsWhenSidebarBecomesInactive.tsx new file mode 100644 index 00000000000..a91cb1c1689 --- /dev/null +++ b/apps/extension/src/app/navigation/HideContentsWhenSidebarBecomesInactive.tsx @@ -0,0 +1,31 @@ +import { PropsWithChildren, useEffect } from 'react' +import { Flex } from 'ui/src' +import { useIsChromeWindowFocusedWithTimeout } from 'uniswap/src/extension/useIsChromeWindowFocused' +import { ONE_MINUTE_MS } from 'utilities/src/time/time' +import { LandingBackground } from 'wallet/src/components/landing/LandingBackground' +import { useWalletNavigation } from 'wallet/src/contexts/WalletNavigationContext' + +// The sidebar becomes "inactive" when this amount of time has passed since the window lost focus. +const INACTIVITY_TIMEOUT = 15 * ONE_MINUTE_MS + +export function HideContentsWhenSidebarBecomesInactive({ children }: PropsWithChildren): JSX.Element { + const isChromeWindowFocused = useIsChromeWindowFocusedWithTimeout(INACTIVITY_TIMEOUT) + + const { navigateToAccountTokenList } = useWalletNavigation() + + useEffect(() => { + if (!isChromeWindowFocused) { + // We navigate to the homepage because we'll lose the local state when the sidebar becomes active again, + // and we want to avoid the user making mistakes because their swap/flow state was lost. + navigateToAccountTokenList() + } + }, [isChromeWindowFocused, navigateToAccountTokenList]) + + return isChromeWindowFocused ? ( + <>{children} + ) : ( + + + + ) +} diff --git a/apps/extension/src/app/navigation/SideBarNavigationProvider.tsx b/apps/extension/src/app/navigation/SideBarNavigationProvider.tsx new file mode 100644 index 00000000000..7ae244b5d0e --- /dev/null +++ b/apps/extension/src/app/navigation/SideBarNavigationProvider.tsx @@ -0,0 +1,186 @@ +import { PropsWithChildren, useCallback } from 'react' +import { createSearchParams, useNavigate } from 'react-router-dom' +import { useCopyToClipboard } from 'src/app/hooks/useOnCopyToClipboard' +import { AppRoutes, HomeQueryParams, HomeTabs } from 'src/app/navigation/constants' +import { navigate } from 'src/app/navigation/state' +import { focusOrCreateTokensExploreTab } from 'src/app/navigation/utils' +import { uniswapUrls } from 'uniswap/src/constants/urls' +import { WalletEventName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { UniverseChainId } from 'uniswap/src/types/chains' +import { ShareableEntity } from 'uniswap/src/types/sharing' +import { logger } from 'utilities/src/logger/logger' +import { + NavigateToNftItemArgs, + NavigateToSendFlowArgs, + NavigateToSwapFlowArgs, + ShareNftArgs, + ShareTokenArgs, + WalletNavigationProvider, + getNavigateToSendFlowArgsInitialState, + getNavigateToSwapFlowArgsInitialState, +} from 'wallet/src/contexts/WalletNavigationContext' +import { CopyNotificationType } from 'wallet/src/features/notifications/types' +import { TransactionState } from 'wallet/src/features/transactions/transactionState/types' +import { ExplorerDataType, getExplorerLink, getNftUrl, getTokenUrl } from 'wallet/src/utils/linking' + +export type SidebarLocationState = + | { + initialTransactionState?: TransactionState + } + | undefined + +export function SideBarNavigationProvider({ children }: PropsWithChildren): JSX.Element { + const handleShareNft = useHandleShareNft() + const handleShareToken = useHandleShareToken() + const navigateToAccountActivityList = useNavigateToAccountActivityList() + const navigateToAccountTokenList = useNavigateToAccountTokenList() + const navigateToBuyOrReceiveWithEmptyWallet = useNavigateToBuyOrReceiveWithEmptyWallet() + const navigateToNftDetails = useNavigateToNftDetails() + const navigateToReceive = useNavigateToReceive() + const navigateToSend = useNavigateToSend() + const navigateToSwapFlow = useNavigateToSwapFlow() + const navigateToTokenDetails = useNavigateToTokenDetails() + const navigateToNftCollection = useCallback(() => { + // no-op until we have proper NFT collection + }, []) + + return ( + + {children} + + ) +} + +function useHandleShareNft(): (args: ShareNftArgs) => void { + const copyToClipboard = useCopyToClipboard() + + return useCallback( + async ({ contractAddress, tokenId }: ShareNftArgs): Promise => { + const url = getNftUrl(contractAddress, tokenId) + + await copyToClipboard({ textToCopy: url, copyType: CopyNotificationType.NftUrl }) + + sendAnalyticsEvent(WalletEventName.ShareButtonClicked, { + entity: ShareableEntity.NftItem, + url, + }) + }, + [copyToClipboard], + ) +} + +function useHandleShareToken(): (args: ShareTokenArgs) => void { + const copyToClipboard = useCopyToClipboard() + + return useCallback( + async ({ currencyId }: ShareTokenArgs): Promise => { + const url = getTokenUrl(currencyId) + + if (!url) { + logger.error(new Error('Failed to get token URL'), { + tags: { file: 'SideBarNavigationProvider.tsx', function: 'useHandleShareToken' }, + extra: { currencyId }, + }) + return + } + + await copyToClipboard({ textToCopy: url, copyType: CopyNotificationType.TokenUrl }) + + sendAnalyticsEvent(WalletEventName.ShareButtonClicked, { + entity: ShareableEntity.Token, + url, + }) + }, + [copyToClipboard], + ) +} + +function useNavigateToAccountActivityList(): () => void { + // TODO(EXT-1029): determine why we need useNavigate here + const navigateFix = useNavigate() + + return useCallback( + (): void => + navigateFix({ + pathname: AppRoutes.Home, + search: createSearchParams({ + [HomeQueryParams.Tab]: HomeTabs.Activity, + }).toString(), + }), + [navigateFix], + ) +} + +function useNavigateToAccountTokenList(): () => void { + // TODO(EXT-1029): determine why we need useNavigate here + const navigateFix = useNavigate() + + return useCallback( + (): void => + navigateFix({ + pathname: AppRoutes.Home, + search: createSearchParams({ + [HomeQueryParams.Tab]: HomeTabs.Tokens, + }).toString(), + }), + [navigateFix], + ) +} + +function useNavigateToReceive(): () => void { + return useCallback((): void => navigate(AppRoutes.Receive), []) +} + +function useNavigateToSend(): (args: NavigateToSendFlowArgs) => void { + return useCallback((args: NavigateToSendFlowArgs): void => { + const initialState = getNavigateToSendFlowArgsInitialState(args) + + const state: SidebarLocationState = args ? { initialTransactionState: initialState } : undefined + + navigate(AppRoutes.Transfer, { state }) + }, []) +} + +function useNavigateToSwapFlow(): (args: NavigateToSwapFlowArgs) => void { + return useCallback((args: NavigateToSwapFlowArgs): void => { + const initialState = getNavigateToSwapFlowArgsInitialState(args) + + const state: SidebarLocationState = initialState ? { initialTransactionState: initialState } : undefined + + navigate(AppRoutes.Swap, { state }) + }, []) +} + +function useNavigateToTokenDetails(): (currencyId: string) => void { + return useCallback(async (currencyId: string): Promise => { + await focusOrCreateTokensExploreTab({ currencyId }) + }, []) +} + +function useNavigateToNftDetails(): (args: NavigateToNftItemArgs) => void { + return useCallback(({ address, tokenId, chainId }: NavigateToNftItemArgs): void => { + // eslint-disable-next-line security/detect-non-literal-fs-filename + window.open(getExplorerLink(chainId ?? UniverseChainId.Mainnet, `${address}/${tokenId}`, ExplorerDataType.NFT)) + }, []) +} + +function useNavigateToBuyOrReceiveWithEmptyWallet(): () => void { + return useCallback((): void => { + // TODO(EXT-669): replace this once we have an onramp in the Extension. + // eslint-disable-next-line security/detect-non-literal-fs-filename + window.open(uniswapUrls.helpArticleUrls.moonpayHelp, '_blank') + }, []) +} diff --git a/apps/extension/src/app/navigation/constants.ts b/apps/extension/src/app/navigation/constants.ts new file mode 100644 index 00000000000..7ada8b59b50 --- /dev/null +++ b/apps/extension/src/app/navigation/constants.ts @@ -0,0 +1,42 @@ +export { HomeTabs } from 'uniswap/src/types/screens/extension' + +export enum TopLevelRoutes { + Onboarding = 'onboarding', + Notifications = 'notifications', +} + +export enum OnboardingRoutes { + Import = 'import', + Create = 'create', + Scan = 'scan', + Reset = 'reset', + ResetScan = 'reset-scan', + UnsupportedBrowser = 'unsupported-browser', +} + +export enum AppRoutes { + AccountSwitcher = 'account-switcher', + Home = '', + Receive = 'receive', + Requests = 'requests', + Settings = 'settings', + Swap = 'swap', + Transfer = 'transfer', +} + +export enum HomeQueryParams { + Tab = 'tab', +} + +export enum SettingsRoutes { + ChangePassword = 'change-password', + DevMenu = 'dev-menu', + ViewRecoveryPhrase = 'view-recovery-phrase', + RemoveRecoveryPhrase = 'remove-recovery-phrase', + Privacy = 'privacy', +} + +export enum RemoveRecoveryPhraseRoutes { + Wallets = 'wallets', + Verify = 'verify', +} diff --git a/apps/extension/src/app/navigation/index.tsx b/apps/extension/src/app/navigation/index.tsx new file mode 100644 index 00000000000..cc25f3a6a91 --- /dev/null +++ b/apps/extension/src/app/navigation/index.tsx @@ -0,0 +1,247 @@ +import { useCallback, useMemo, useRef } from 'react' +import { Outlet, useLocation } from 'react-router-dom' +import { FeedbackRequestModal } from 'src/app/components/modal/FeedbackRequestModal' +import { DappRequestWrapper } from 'src/app/features/dappRequests/DappRequestContent' +import { DappRequestQueueProvider } from 'src/app/features/dappRequests/DappRequestQueueContext' +import { HomeScreen } from 'src/app/features/home/HomeScreen' +import { Locked } from 'src/app/features/lockScreen/Locked' +import { NotificationToastWrapper } from 'src/app/features/notifications/NotificationToastWrapper' +import { StorageWarningModal } from 'src/app/features/warnings/StorageWarningModal' +import { useIsWalletUnlocked } from 'src/app/hooks/useIsWalletUnlocked' +import { HideContentsWhenSidebarBecomesInactive } from 'src/app/navigation/HideContentsWhenSidebarBecomesInactive' +import { SideBarNavigationProvider } from 'src/app/navigation/SideBarNavigationProvider' +import { AppRoutes } from 'src/app/navigation/constants' +import { useRouterState } from 'src/app/navigation/state' +import { focusOrCreateOnboardingTab } from 'src/app/navigation/utils' +import { isOnboardedSelector } from 'src/app/utils/isOnboardedSelector' +import { useAppSelector } from 'src/store/store' +import { AnimatePresence, Flex, SpinningLoader, styled } from 'ui/src' +import { useIsChromeWindowFocusedWithTimeout } from 'uniswap/src/extension/useIsChromeWindowFocused' +import { useAsyncData } from 'utilities/src/react/hooks' +import { ONE_SECOND_MS } from 'utilities/src/time/time' +import { TransactionHistoryUpdater } from 'wallet/src/features/transactions/TransactionHistoryUpdater' +import { QueuedOrderModal } from 'wallet/src/features/transactions/swap/modals/QueuedOrderModal' + +export function MainContent(): JSX.Element { + const isOnboarded = useAppSelector(isOnboardedSelector) + + if (!isOnboarded) { + // TODO: add an error state that takes the user to fullscreen onboarding + throw new Error('you should have onboarded') + } + + return ( + <> + + + + ) +} + +enum Direction { + Left = 'left', + Right = 'right', + Up = 'up', + Down = 'down', +} + +const oppositeDirection = { + [Direction.Left]: Direction.Right, + [Direction.Right]: Direction.Left, + [Direction.Up]: Direction.Down, + [Direction.Down]: Direction.Up, +} + +// default is Right +const routeDirections = { + [AppRoutes.AccountSwitcher]: Direction.Up, + [AppRoutes.Swap]: Direction.Down, + [AppRoutes.Home]: Direction.Right, + [AppRoutes.Requests]: Direction.Right, + [AppRoutes.Receive]: Direction.Down, + [AppRoutes.Settings]: Direction.Right, + [AppRoutes.Transfer]: Direction.Down, +} satisfies Record + +const getAppRouteFromPathName = (pathname: string): AppRoutes | null => { + const val = (pathname.split('/')[1] || '') as AppRoutes + if (Object.values(AppRoutes).includes(val)) { + return val + } + return null +} + +export function WebNavigation(): JSX.Element { + const isLoggedIn = useIsWalletUnlocked() + const { pathname } = useLocation() + const history = useRef([]).current + if (history[0] !== pathname) { + history.unshift(pathname) + } + + let towards = Direction.Right + const routeName = getAppRouteFromPathName(pathname) + const routerState = useRouterState() + if (routeName != null) { + towards = routeDirections[routeName] + const isBackwards = routerState?.historyAction === 'POP' + if (isBackwards) { + const lastRoute = getAppRouteFromPathName(history[1] || '') + const previousDirection = lastRoute ? routeDirections[lastRoute] : 'right' + towards = oppositeDirection[previousDirection] + } + } + + const childrenMemo = useMemo(() => { + return ( + + + + {isLoggedIn === null ? ( + + ) : isLoggedIn === true ? ( + + + + ) : ( + + )} + + + + ) + }, [isLoggedIn, pathname, towards]) + + return ( + + + {childrenMemo} + + ) +} + +// TODO(EXT-994): improve this loading screen. +function Loading(): JSX.Element { + return ( + + + + ) +} + +const AnimatedPane = styled(Flex, { + zIndex: 1, + fill: true, + position: 'absolute', + inset: 0, + x: 0, + opacity: 1, + maxWidth: 'calc(min(495px, 100vw))', + minHeight: '100vh', + mx: 'auto', + width: '100%', + + variants: { + towards: (dir: Direction) => ({ + enterStyle: { + x: isVertical(dir) ? 0 : dir === 'right' ? 30 : -30, + y: !isVertical(dir) ? 0 : dir === 'down' ? 15 : -15, + opacity: 0, + zIndex: 1, + }, + exitStyle: { + zIndex: 0, + x: isVertical(dir) ? 0 : dir === 'left' ? 30 : -30, + y: !isVertical(dir) ? 0 : dir === 'up' ? 15 : -15, + opacity: 0, + }, + }), + } as const, +}) + +const isVertical = (dir: Direction): boolean => dir === 'up' || dir === 'down' + +function useConstant(c: A): A { + const out = useRef() + if (!out.current) { + out.current = c + } + return out.current +} + +function LoggedIn(): JSX.Element { + /** + * + * So, rendering directly means the internal hooks in Outlet + * will update instantly on page change, but we don't want that. + * + * Instead we run an animation on page change and keep the old page around + * until the animation completes. + * + * So what this does is "unwraps" the Outlet component in a sense, the hooks + * actually run inside *this* component instead of inside the sub-component + * Outlet. + * + * Then we wrap that in `useConstant` so it never changes. + * + * This makes it so the old page doesn't render with the new page contents + * as it does its exit animation. + * + **/ + const outletContents = Outlet({}) + const contents = useConstant(outletContents) + const pendingDappRequests = useAppSelector((state) => state.dappRequests.pending) + const areRequestsPending = pendingDappRequests.length > 0 + + // To avoid excessive API calls, we pause the transaction history updater a short time after the window loses focus. + const isChromeWindowFocused = useIsChromeWindowFocusedWithTimeout(30 * ONE_SECOND_MS) + + return ( + <> + {contents} + + + + + + {isChromeWindowFocused && } + + {areRequestsPending && ( + + + + )} + + ) +} + +function LoggedOut(): JSX.Element { + const isOnboarded = useAppSelector(isOnboardedSelector) + const didOpenOnboarding = useRef(false) + + const handleOnboarding = useCallback(async () => { + if (!isOnboarded && !didOpenOnboarding.current) { + // We keep track of this to avoid opening the onboarding page multiple times if this component remounts. + didOpenOnboarding.current = true + await focusOrCreateOnboardingTab() + // Automatically close the pop up after focusing on the onboarding tab. + window.close() + } + }, [isOnboarded]) + + useAsyncData(handleOnboarding) + + // If the user has not onboarded, we render nothing and let the `useEffect` above automatically close the popup. + // We could consider showing a loading spinner while the popup is being closed. + return isOnboarded ? : <> +} diff --git a/apps/extension/src/app/navigation/state.ts b/apps/extension/src/app/navigation/state.ts new file mode 100644 index 00000000000..1dc49187dc2 --- /dev/null +++ b/apps/extension/src/app/navigation/state.ts @@ -0,0 +1,86 @@ +import { RouterState } from '@sentry/react/types/types' +import { useEffect, useState } from 'react' +import { Router } from 'react-router-dom' +import { sentryCreateHashRouter } from 'src/app/sentry' + +/** + * Note this file is separate from SidebarApp on purpose! + * + * Because the router imports all the top-level pages, you can't import it from + * below those pages without causing circular imports. + * + * Circular imports break many things - HMR, bundle splitting, tree shaking, + * etc. + * + * So instead we use this file as a way to "push" the router into an import that + * is safe from circularity. + */ + +type RouterStateListener = (state: RouterState) => void + +let state: RouterState | null = null + +const listeners = new Set() + +export function setRouterState(next: RouterState): void { + state = next + listeners.forEach((l) => l(next)) +} + +export function getRouterState(): RouterState | null { + return state +} + +export function subscribeToRouterState(listener: RouterStateListener): () => void { + listeners.add(listener) + + if (state) { + listener(state) + } + + return () => { + listeners.delete(listener) + } +} + +export function useRouterState(): RouterState | null { + const [val, setVal] = useState(state) + + useEffect(() => { + return subscribeToRouterState(setVal) + }, []) + + return val +} + +// as far as i can tell, react-router-dom doesn't give us this type so have to work around +type Router = ReturnType + +let router: Router | null = null + +export function setRouter(next: Router): void { + router = next +} + +export function getRouter(): Router { + if (!router) { + throw new Error('Invalid call to `getRouter` before the router was initialized') + } + return router +} + +type RouterNavigate = Router['navigate'] +type RouterNavigateArgs = Parameters + +// this is a navigate that doesn't need any useNavigate() hook, which in react router has performance issues: +// https://github.com/remix-run/react-router/issues/7634#issuecomment-1306650156 +// note: useNavigation().navigate() returns void, so making this match that function for easier swapping out +export const navigate = (to: RouterNavigateArgs[0] | number, opts?: RouterNavigateArgs[1]): void => { + if (typeof to === 'number') { + // eslint-disable-next-line no-void + void getRouter().navigate(to) + return + } + // eslint-disable-next-line no-void + void getRouter().navigate(to, opts) +} diff --git a/apps/extension/src/app/navigation/utils.ts b/apps/extension/src/app/navigation/utils.ts new file mode 100644 index 00000000000..7426f645614 --- /dev/null +++ b/apps/extension/src/app/navigation/utils.ts @@ -0,0 +1,141 @@ +import { To, matchPath, useLocation } from 'react-router-dom' +import { SidebarLocationState } from 'src/app/navigation/SideBarNavigationProvider' +import { TopLevelRoutes } from 'src/app/navigation/constants' +import { navigate } from 'src/app/navigation/state' +import { onboardingMessageChannel } from 'src/background/messagePassing/messageChannels' +import { OnboardingMessageType } from 'src/background/messagePassing/types/ExtensionMessages' +import { uniswapUrls } from 'uniswap/src/constants/urls' +import { logger } from 'utilities/src/logger/logger' +import { escapeRegExp } from 'utilities/src/primitives/string' +import { getTokenUrl } from 'wallet/src/utils/linking' + +export function useRouteMatch(pathToMatch: string): boolean { + const { pathname } = useLocation() + + return !!matchPath(pathToMatch, pathname) +} + +export const useExtensionNavigation = (): { + navigateTo: (path: To) => void + navigateBack: () => void + locationState: SidebarLocationState +} => { + const navigateTo = (path: To): void => navigate(path) + const navigateBack = (): void => navigate(-1) + const locationState = useLocation().state as SidebarLocationState + + return { navigateTo, navigateBack, locationState } +} + +export async function focusOrCreateOnboardingTab(page?: string): Promise { + const extension = await chrome.management.getSelf() + + const tabs = await chrome.tabs.query({ url: `chrome-extension://${extension.id}/*` }) + const tab = tabs[0] + + const url = 'onboarding.html#/' + (page ? page : TopLevelRoutes.Onboarding) + + if (!tab?.id) { + await chrome.tabs.create({ url }) + return + } + + await chrome.tabs.update(tab.id, { + active: true, + highlighted: true, + // We only want to update the URL if we're navigating to a specific page. + // Otherwise, just focus the existing tab without overriding the current URL. + url: page ? url : undefined, + }) + + if (page) { + // When navigating to a specific page, we need to reload the tab to ensure that the app state is reset and the store synchronization is properly initialized. + // This is necessary to handle the edge case where the user leaves a completed onboarding tab open (with synchronization paused) + // and then clicks on the "forgot password" link. + await chrome.tabs.reload(tab.id) + } + + await chrome.windows.update(tab.windowId, { focused: true }) + + await onboardingMessageChannel.sendMessage({ + type: OnboardingMessageType.HighlightOnboardingTab, + }) +} + +/** + * To avoid opening too many tabs while also ensuring that we don't take over the user's active tab, + * we only update the URL of the active tab if it's already in a specific route of the Uniswap interface. + * + * If the current tab is not in that route, we open a new tab instead. + */ +export async function focusOrCreateUniswapInterfaceTab({ + url, + reuseActiveTabIfItMatches, +}: { + url: string + reuseActiveTabIfItMatches?: RegExp +}): Promise { + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }) + + const activeTab = tabs[0] + const activeTabUrl = activeTab?.url + + const isInNewTabPage = activeTabUrl === 'chrome://newtab/' + + const shouldReuseActiveTab = reuseActiveTabIfItMatches + ? activeTabUrl && reuseActiveTabIfItMatches?.test(activeTabUrl) + : false + + if (activeTab?.id && (shouldReuseActiveTab || isInNewTabPage)) { + await chrome.tabs.update(activeTab.id, { + active: true, + highlighted: true, + url, + }) + return + } + + await chrome.tabs.create({ url }) +} + +export async function focusOrCreateTokensExploreTab({ currencyId }: { currencyId: string }): Promise { + const url = getTokenUrl(currencyId) + + if (!url) { + logger.error(new Error('Failed to get token URL'), { + tags: { file: 'navigation/utils.ts', function: 'focusOrCreateTokensExploreTab' }, + extra: { currencyId }, + }) + return + } + + return focusOrCreateUniswapInterfaceTab({ + url, + // We want to reuse the active tab only if it's already in any other TDP. + // eslint-disable-next-line security/detect-non-literal-regexp + reuseActiveTabIfItMatches: new RegExp(`^${escapeRegExp(uniswapUrls.webInterfaceTokensUrl)}`), + }) +} + +export async function focusOrCreateNftItemTab({ + address, + tokenId, +}: { + address: string + tokenId: string +}): Promise { + return focusOrCreateUniswapInterfaceTab({ + url: `${uniswapUrls.webInterfaceNftItemUrl}/${address}/${tokenId}`, + // We want to reuse the active tab only if it's already in any other NFT item page. + // eslint-disable-next-line security/detect-non-literal-regexp + reuseActiveTabIfItMatches: new RegExp(`^${escapeRegExp(uniswapUrls.webInterfaceNftItemUrl)}`), + }) +} + +export async function getCurrentTabAndWindowId(): Promise<{ tabId: number; windowId: number }> { + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }) + if (tabs.length === 0 || !tabs[0] || typeof tabs[0].id !== 'number' || typeof tabs[0].windowId !== 'number') { + throw new Error('No active tab found or missing tab/window ID') + } + return { tabId: tabs[0].id, windowId: tabs[0].windowId } +} diff --git a/apps/extension/src/app/saga.ts b/apps/extension/src/app/saga.ts new file mode 100644 index 00000000000..86d67b549dd --- /dev/null +++ b/apps/extension/src/app/saga.ts @@ -0,0 +1,99 @@ +import { initDappStore } from 'src/app/features/dapp/saga' +import { dappRequestApprovalWatcher } from 'src/app/features/dappRequests/dappRequestApprovalWatcherSaga' +import { dappRequestWatcher } from 'src/app/features/dappRequests/saga' +import { call, spawn } from 'typed-redux-saga' +import { apolloClientRef } from 'wallet/src/data/apollo/usePersistedApolloClient' +import { authActions, authReducer, authSaga, authSagaName } from 'wallet/src/features/auth/saga' +import { appLanguageWatcherSaga } from 'wallet/src/features/language/saga' +import { initProviders } from 'wallet/src/features/providers' +import { swapActions, swapReducer, swapSaga, swapSagaName } from 'wallet/src/features/transactions/swap/swapSaga' +import { + tokenWrapActions, + tokenWrapReducer, + tokenWrapSaga, + tokenWrapSagaName, +} from 'wallet/src/features/transactions/swap/wrapSaga' +import { transactionWatcher, watchTransactionEvents } from 'wallet/src/features/transactions/transactionWatcherSaga' +import { + editAccountActions, + editAccountReducer, + editAccountSaga, + editAccountSagaName, +} from 'wallet/src/features/wallet/accounts/editAccountSaga' +import { + createAccountsActions, + createAccountsReducer, + createAccountsSaga, + createAccountsSagaName, +} from 'wallet/src/features/wallet/create/createAccountsSaga' +import { MonitoredSaga, getMonitoredSagaReducers } from 'wallet/src/state/saga' + +// Stateful sagas that are registered with the store on startup +export const monitoredSagas: Record = { + [authSagaName]: { + name: authSagaName, + wrappedSaga: authSaga, + reducer: authReducer, + actions: authActions, + }, + [createAccountsSagaName]: { + name: createAccountsSagaName, + wrappedSaga: createAccountsSaga, + reducer: createAccountsReducer, + actions: createAccountsActions, + }, + [editAccountSagaName]: { + name: editAccountSagaName, + wrappedSaga: editAccountSaga, + reducer: editAccountReducer, + actions: editAccountActions, + }, + [swapSagaName]: { + name: swapSagaName, + wrappedSaga: swapSaga, + reducer: swapReducer, + actions: swapActions, + }, + [tokenWrapSagaName]: { + name: tokenWrapSagaName, + wrappedSaga: tokenWrapSaga, + reducer: tokenWrapReducer, + actions: tokenWrapActions, + }, +} as const + +const sagasInitializedOnStartup = [ + appLanguageWatcherSaga, + initDappStore, + dappRequestApprovalWatcher, + dappRequestWatcher, + initProviders, + watchTransactionEvents, +] as const + +export const monitoredSagaReducers = getMonitoredSagaReducers(monitoredSagas) + +export function* webRootSaga() { + for (const s of sagasInitializedOnStartup) { + yield* spawn(s) + } + + const apolloClient = yield* call(apolloClientRef.onReady) + yield* spawn(transactionWatcher, { apolloClient }) + + for (const m of Object.values(monitoredSagas)) { + yield* spawn(m.wrappedSaga) + } +} + +const onboardingSagasInitializedOnStartup = [initProviders] as const + +export function* onboardingRootSaga() { + for (const s of onboardingSagasInitializedOnStartup) { + yield* spawn(s) + } + + for (const m of Object.values(monitoredSagas)) { + yield* spawn(m.wrappedSaga) + } +} diff --git a/apps/extension/src/app/sentry.ts b/apps/extension/src/app/sentry.ts new file mode 100644 index 00000000000..33515d8a76c --- /dev/null +++ b/apps/extension/src/app/sentry.ts @@ -0,0 +1,88 @@ +import * as SentryBrowser from '@sentry/browser' +import * as Sentry from '@sentry/react' +import { setTag } from '@sentry/react' +import { useEffect } from 'react' +import { + createHashRouter, + createRoutesFromChildren, + matchRoutes, + useLocation, + useNavigationType, +} from 'react-router-dom' +import { getSentryEnvironment } from 'src/app/version' +import { config } from 'uniswap/src/config' +import { logger } from 'utilities/src/logger/logger' +import { beforeSend } from 'wallet/src/utils/sentry' + +export const enum SentryAppNameTag { + Sidebar = 'sidebar', + Onboarding = 'onboarding', + ContentScript = 'content-script', + Background = 'background', +} + +export function initializeSentry(appNameTag: SentryAppNameTag, sentryUserId: string): void { + if (__DEV__) { + return + } + Sentry.init({ + environment: getSentryEnvironment(), + dsn: config.sentryDsn, + release: process.env.VERSION, + integrations: [ + new Sentry.BrowserTracing({ + // See docs for support of different versions of variation of react router + // https://docs.sentry.io/platforms/javascript/guides/react/configuration/integrations/react-router/ + routingInstrumentation: Sentry.reactRouterV6Instrumentation( + useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + ), + }), + ], + beforeSend, + ...sentrySampleRateOptions, + }) + setTag('appName', appNameTag) + Sentry.setUser({ id: sentryUserId }) +} + +export function initSentryForBrowserScripts(appNameTag: SentryAppNameTag, sentryUserId: string): void { + if (__DEV__) { + return + } + + // Wrapped in try/catch because in this context it can fail silently + try { + SentryBrowser.init({ + environment: getSentryEnvironment(), + dsn: config.sentryDsn, + release: process.env.VERSION, + // TODO (EXT-528): Look into adding tracing integration + beforeSend, + ...sentrySampleRateOptions, + }) + } catch (e) { + logger.debug('sentry.ts', 'initSentryForBrowserScripts', 'Error in Sentry init', e) + } + setTag('appName', appNameTag) + + if (sentryUserId) { + SentryBrowser.setUser({ id: sentryUserId }) + } +} + +const sentrySampleRateOptions = { + // Set tracesSampleRate to 1.0 to capture 100% + // of transactions for performance monitoring. + tracesSampleRate: 1.0, + + // Capture Replay for 10% of all sessions, + // plus for 100% of sessions with an error + replaysSessionSampleRate: 0.1, + replaysOnErrorSampleRate: 1.0, +} + +export const sentryCreateHashRouter = Sentry.wrapCreateBrowserRouter(createHashRouter) diff --git a/apps/extension/src/app/utils/analytics.ts b/apps/extension/src/app/utils/analytics.ts new file mode 100644 index 00000000000..41920b0a63b --- /dev/null +++ b/apps/extension/src/app/utils/analytics.ts @@ -0,0 +1,23 @@ +import '@tamagui/core/reset.css' +import 'src/app/Global.css' +import 'symbol-observable' // Needed by `reduxed-chrome-storage` as polyfill, order matters + +import { getLocalUserId } from 'src/app/utils/storage' +import { EXTENSION_ORIGIN_APPLICATION } from 'src/app/version' +import { uniswapUrls } from 'uniswap/src/constants/urls' +import { ApplicationTransport } from 'utilities/src/telemetry/analytics/ApplicationTransport' +// eslint-disable-next-line no-restricted-imports +import { analytics, getAnalyticsAtomDirect } from 'utilities/src/telemetry/analytics/analytics' + +export async function initExtensionAnalytics(): Promise { + const analyticsAllowed = await getAnalyticsAtomDirect(true) + await analytics.init( + new ApplicationTransport({ + serverUrl: uniswapUrls.amplitudeProxyUrl, + appOrigin: EXTENSION_ORIGIN_APPLICATION, + }), + analyticsAllowed, + undefined, + getLocalUserId, + ) +} diff --git a/apps/extension/src/app/utils/chrome.ts b/apps/extension/src/app/utils/chrome.ts new file mode 100644 index 00000000000..9433b4c9271 --- /dev/null +++ b/apps/extension/src/app/utils/chrome.ts @@ -0,0 +1,17 @@ +/** + * Helper function to detect if user is using arc chromium browser + * Will not work until stylesheets are loaded + * @returns true if user is using arc browser + */ +export function isArcBrowser(): boolean { + return !!getComputedStyle(document.documentElement).getPropertyValue('--arc-palette-background') +} + +/** + * Helper function to check if chome extension environment supports side panel + * Arc browser has the functions defined but does not do anything so needs to be explicitly checked + * @returns true if chrome environment supports side panel + */ +export function checksIfSupportsSidePanel(): boolean { + return !!chrome.sidePanel && !isArcBrowser() +} diff --git a/apps/extension/src/app/utils/devtools.ts b/apps/extension/src/app/utils/devtools.ts new file mode 100644 index 00000000000..fe355ad4b6f --- /dev/null +++ b/apps/extension/src/app/utils/devtools.ts @@ -0,0 +1,3 @@ +if (process.env.NODE_ENV === 'development' && window.location.search.includes('why-did-you-render')) { + require('./whyDidYouRender') +} diff --git a/apps/extension/src/app/utils/isAppleDevice.test.ts b/apps/extension/src/app/utils/isAppleDevice.test.ts new file mode 100644 index 00000000000..558774ab472 --- /dev/null +++ b/apps/extension/src/app/utils/isAppleDevice.test.ts @@ -0,0 +1,56 @@ +import { isAppleDevice } from 'src/app/utils/isAppleDevice' + +describe('isAppleDevice', () => { + beforeEach(() => { + // Reset any mocks before each test + jest.resetModules() + }) + + it('should return true for macOS', () => { + Object.defineProperty(window.navigator, 'platform', { + value: 'MacIntel', + writable: true, + }) + expect(isAppleDevice()).toBe(true) + }) + + it('should return true for iPhone', () => { + Object.defineProperty(window.navigator, 'platform', { + value: 'iPhone', + writable: true, + }) + expect(isAppleDevice()).toBe(true) + }) + + it('should return true for iPad', () => { + Object.defineProperty(window.navigator, 'platform', { + value: 'iPad', + writable: true, + }) + expect(isAppleDevice()).toBe(true) + }) + + it('should return false for Windows', () => { + Object.defineProperty(window.navigator, 'platform', { + value: 'Win32', + writable: true, + }) + expect(isAppleDevice()).toBe(false) + }) + + it('should return false for Linux', () => { + Object.defineProperty(window.navigator, 'platform', { + value: 'Linux', + writable: true, + }) + expect(isAppleDevice()).toBe(false) + }) + + it('should return false for Android', () => { + Object.defineProperty(window.navigator, 'platform', { + value: 'Android', + writable: true, + }) + expect(isAppleDevice()).toBe(false) + }) +}) diff --git a/apps/extension/src/app/utils/isAppleDevice.ts b/apps/extension/src/app/utils/isAppleDevice.ts new file mode 100644 index 00000000000..48adc3955a2 --- /dev/null +++ b/apps/extension/src/app/utils/isAppleDevice.ts @@ -0,0 +1,7 @@ +/** + * Checks if the operating system is macOS. + * @returns {boolean} - True if the OS is macOS, otherwise false. + */ +export function isAppleDevice(): boolean { + return /Mac|iPod|iPhone|iPad/.test(navigator.platform) +} diff --git a/apps/extension/src/app/utils/isOnboardedSelector.ts b/apps/extension/src/app/utils/isOnboardedSelector.ts new file mode 100644 index 00000000000..6add25ea522 --- /dev/null +++ b/apps/extension/src/app/utils/isOnboardedSelector.ts @@ -0,0 +1,5 @@ +import { AppSelector } from 'wallet/src/state' + +export const isOnboardedSelector: AppSelector = (state) => { + return Object.values(state.wallet.accounts).length > 0 +} diff --git a/apps/extension/src/app/utils/storage.ts b/apps/extension/src/app/utils/storage.ts new file mode 100644 index 00000000000..1107161fd20 --- /dev/null +++ b/apps/extension/src/app/utils/storage.ts @@ -0,0 +1,18 @@ +import { v4 as uuidv4 } from 'uuid' +import { PersistedStorage } from 'wallet/src/utils/persistedStorage' + +const STORAGE_AREA_KEY = 'local' +export const USER_ID_KEY = 'USER_ID' +export const LOCAL_STORAGE = new PersistedStorage(STORAGE_AREA_KEY) + +export async function getLocalUserId(): Promise { + let userId: string | undefined = await LOCAL_STORAGE.getItem(USER_ID_KEY) + + if (userId) { + return userId + } + + userId = uuidv4() + await LOCAL_STORAGE.setItem(USER_ID_KEY, userId) + return userId +} diff --git a/apps/extension/src/app/utils/whyDidYouRender.ts b/apps/extension/src/app/utils/whyDidYouRender.ts new file mode 100644 index 00000000000..1f0d3645831 --- /dev/null +++ b/apps/extension/src/app/utils/whyDidYouRender.ts @@ -0,0 +1,13 @@ +import whyDidYouRender from '@welldone-software/why-did-you-render' +import React from 'react' + +if (process.env.NODE_ENV === 'development') { + whyDidYouRender(React, { + // use this to filter down to specific component names, ie /Select.*/ + include: [/.*/], + collapseGroups: true, + logOnDifferentValues: true, + trackAllPureComponents: true, + trackHooks: true, + }) +} diff --git a/apps/extension/src/app/version.ts b/apps/extension/src/app/version.ts new file mode 100644 index 00000000000..a1b425aa712 --- /dev/null +++ b/apps/extension/src/app/version.ts @@ -0,0 +1,31 @@ +import { isBetaEnv, isDevEnv } from 'utilities/src/environment' +import { StatsigEnvironmentTier } from 'wallet/src/version' + +// TODO: Add to analytics package and remove +export const EXTENSION_ORIGIN_APPLICATION = 'extension' + +export function getStatsigEnvironmentTier(): StatsigEnvironmentTier { + if (isDevEnv()) { + return StatsigEnvironmentTier.DEV + } + if (isBetaEnv()) { + return StatsigEnvironmentTier.BETA + } + return StatsigEnvironmentTier.PROD +} + +export function getSentryEnvironment(): SentryEnvironment { + if (isDevEnv()) { + return SentryEnvironment.DEV + } + if (isBetaEnv()) { + return SentryEnvironment.BETA + } + return SentryEnvironment.PROD +} + +enum SentryEnvironment { + DEV = 'development', + BETA = 'beta', + PROD = 'production', +} diff --git a/apps/extension/src/assets/beta-logo.png b/apps/extension/src/assets/beta-logo.png new file mode 100644 index 00000000000..a8e2387a9f7 Binary files /dev/null and b/apps/extension/src/assets/beta-logo.png differ diff --git a/apps/extension/src/assets/fonts/Basel-Book.woff b/apps/extension/src/assets/fonts/Basel-Book.woff new file mode 100644 index 00000000000..7cfd4abb6e9 Binary files /dev/null and b/apps/extension/src/assets/fonts/Basel-Book.woff differ diff --git a/apps/extension/src/assets/fonts/Basel-Medium.woff b/apps/extension/src/assets/fonts/Basel-Medium.woff new file mode 100644 index 00000000000..004a41fcb1c Binary files /dev/null and b/apps/extension/src/assets/fonts/Basel-Medium.woff differ diff --git a/apps/extension/src/assets/fonts/Inter-normal.var.ttf b/apps/extension/src/assets/fonts/Inter-normal.var.ttf new file mode 100644 index 00000000000..600b384ad81 Binary files /dev/null and b/apps/extension/src/assets/fonts/Inter-normal.var.ttf differ diff --git a/apps/extension/src/assets/graphics/extension-preview-dark.png b/apps/extension/src/assets/graphics/extension-preview-dark.png new file mode 100644 index 00000000000..11145a70ae2 Binary files /dev/null and b/apps/extension/src/assets/graphics/extension-preview-dark.png differ diff --git a/apps/extension/src/assets/graphics/extension-preview-light.png b/apps/extension/src/assets/graphics/extension-preview-light.png new file mode 100644 index 00000000000..f29c5585399 Binary files /dev/null and b/apps/extension/src/assets/graphics/extension-preview-light.png differ diff --git a/apps/extension/src/assets/icon128.png b/apps/extension/src/assets/icon128.png new file mode 100644 index 00000000000..e9c2299dfd5 Binary files /dev/null and b/apps/extension/src/assets/icon128.png differ diff --git a/apps/extension/src/assets/icon16.png b/apps/extension/src/assets/icon16.png new file mode 100644 index 00000000000..3de1e355d12 Binary files /dev/null and b/apps/extension/src/assets/icon16.png differ diff --git a/apps/extension/src/assets/icon32.png b/apps/extension/src/assets/icon32.png new file mode 100644 index 00000000000..071e63e7fd2 Binary files /dev/null and b/apps/extension/src/assets/icon32.png differ diff --git a/apps/extension/src/assets/icon48.png b/apps/extension/src/assets/icon48.png new file mode 100644 index 00000000000..6626bc3b0d0 Binary files /dev/null and b/apps/extension/src/assets/icon48.png differ diff --git a/apps/extension/src/assets/icon64.png b/apps/extension/src/assets/icon64.png new file mode 100644 index 00000000000..cf5e77b53cd Binary files /dev/null and b/apps/extension/src/assets/icon64.png differ diff --git a/apps/extension/src/assets/index.ts b/apps/extension/src/assets/index.ts new file mode 100644 index 00000000000..b9de945e81a --- /dev/null +++ b/apps/extension/src/assets/index.ts @@ -0,0 +1,6 @@ +export const ONBOARDING_BACKGROUND_LIGHT = require('./onboarding-background-light.png') +export const ONBOARDING_BACKGROUND_DARK = require('./onboarding-background-dark.png') +export const LOCK_SCREEN_BACKGROUND = require('./lock-screen-background.png') +export const UNISWAP_BETA_LOGO = require('./beta-logo.png') +export const EXTENSION_PREVIEW_LIGHT = require('./graphics/extension-preview-light.png') +export const EXTENSION_PREVIEW_DARK = require('./graphics/extension-preview-dark.png') diff --git a/apps/extension/src/assets/lock-screen-background.png b/apps/extension/src/assets/lock-screen-background.png new file mode 100644 index 00000000000..61b22ebf394 Binary files /dev/null and b/apps/extension/src/assets/lock-screen-background.png differ diff --git a/apps/extension/src/assets/onboarding-background-dark.png b/apps/extension/src/assets/onboarding-background-dark.png new file mode 100644 index 00000000000..d171047c301 Binary files /dev/null and b/apps/extension/src/assets/onboarding-background-dark.png differ diff --git a/apps/extension/src/assets/onboarding-background-light.png b/apps/extension/src/assets/onboarding-background-light.png new file mode 100644 index 00000000000..9c1296b3011 Binary files /dev/null and b/apps/extension/src/assets/onboarding-background-light.png differ diff --git a/apps/extension/src/background/background.ts b/apps/extension/src/background/background.ts new file mode 100644 index 00000000000..3f8f27d545c --- /dev/null +++ b/apps/extension/src/background/background.ts @@ -0,0 +1,91 @@ +import 'symbol-observable' // Needed by `reduxed-chrome-storage` as polyfill, order matters + +import { initStatSigForBrowserScripts } from 'src/app/StatsigProvider' +import { focusOrCreateOnboardingTab } from 'src/app/navigation/utils' +import { SentryAppNameTag, initSentryForBrowserScripts } from 'src/app/sentry' +import { initExtensionAnalytics } from 'src/app/utils/analytics' +import { getLocalUserId } from 'src/app/utils/storage' +import { initMessageBridge } from 'src/background/backgroundDappRequests' +import { backgroundStore } from 'src/background/backgroundStore' +import { backgroundToSidePanelMessageChannel } from 'src/background/messagePassing/messageChannels' +import { BackgroundToSidePanelRequestType } from 'src/background/messagePassing/types/requests' +import { setSidePanelBehavior, setSidePanelOptions } from 'src/background/utils/chromeSidePanelUtils' +import { readIsOnboardedFromStorage } from 'src/background/utils/persistedStateUtils' +import { logger } from 'utilities/src/logger/logger' + +export const EXTENSION_ID = chrome.runtime.id + +initMessageBridge() + +async function initApp(): Promise { + const userId = await getLocalUserId() + initSentryForBrowserScripts(SentryAppNameTag.Background, userId) + await initStatSigForBrowserScripts() + await initExtensionAnalytics() + + // Enables or disables sidebar based on onboarding status + // Injected script will reject any requests if not onboarded + backgroundStore.addOnboardingChangedListener(async (isOnboarded) => { + if (isOnboarded) { + await enableSidebar() + } else { + await disableSidebar() + await focusOrCreateOnboardingTab() + } + }) + + await backgroundStore.init() +} + +chrome.tabs.onActivated.addListener(onTabChange) +chrome.tabs.onUpdated.addListener(onTabChange) + +chrome.action.onClicked.addListener(async () => { + await checkAndHandleOnboarding() +}) + +chrome.runtime.onInstalled.addListener(async () => { + await checkAndHandleOnboarding() +}) + +// Utility Functions +async function checkAndHandleOnboarding(): Promise { + const isOnboarded = await readIsOnboardedFromStorage() + + if (!isOnboarded) { + await disableSidebar() + await focusOrCreateOnboardingTab() + } else { + await enableSidebar() + } +} + +async function enableSidebar(): Promise { + await setSidePanelOptions({ enabled: true }) + await setSidePanelBehavior({ openPanelOnActionClick: true }) +} + +async function disableSidebar(): Promise { + await setSidePanelOptions({ enabled: false }) + await setSidePanelBehavior({ openPanelOnActionClick: false }) +} + +/** Fires an event whenever a tab is changed so the sidebar can reflect the current connection status properly. */ +async function onTabChange(): Promise { + try { + await backgroundToSidePanelMessageChannel.sendMessage({ + type: BackgroundToSidePanelRequestType.TabActivated, + }) + } catch (e) { + // an error will be thrown if the sidebar is not open. This is expected and in this case there is no action to be taken anyways so ignore. + } +} + +initApp().catch((error) => { + logger.error(error, { + tags: { + file: 'background/background.ts', + function: 'initApp', + }, + }) +}) diff --git a/apps/extension/src/background/backgroundDappRequests.ts b/apps/extension/src/background/backgroundDappRequests.ts new file mode 100644 index 00000000000..12f142cafd3 --- /dev/null +++ b/apps/extension/src/background/backgroundDappRequests.ts @@ -0,0 +1,267 @@ +import { rpcErrors, serializeError } from '@metamask/rpc-errors' +import { removeDappConnection } from 'src/app/features/dapp/actions' +import { changeChain } from 'src/app/features/dapp/changeChain' +import { dappStore } from 'src/app/features/dapp/store' +import { SenderTabInfo } from 'src/app/features/dappRequests/slice' +import { + ChangeChainRequest, + DappRequest, + DappRequestType, + DappResponseType, + RevokePermissionsRequest, +} from 'src/app/features/dappRequests/types/DappRequestTypes' +import { extractBaseUrl } from 'src/app/features/dappRequests/utils' +import { focusOrCreateOnboardingTab } from 'src/app/navigation/utils' +import { + DappBackgroundPortChannel, + contentScriptToBackgroundMessageChannel, + contentScriptUtilityMessageChannel, + createBackgroundToSidePanelMessagePort, + dappResponseMessageChannel, +} from 'src/background/messagePassing/messageChannels' +import { + BackgroundToSidePanelRequestType, + ContentScriptUtilityMessageType, + DappRequestMessage, +} from 'src/background/messagePassing/types/requests' +import { openSidePanel } from 'src/background/utils/chromeSidePanelUtils' +import { ExtensionEthMethods } from 'src/contentScript/methodHandlers/requestMethods' +import { hexadecimalStringToInt, toSupportedChainId } from 'uniswap/src/features/chains/utils' +import { ExtensionEventName } from 'uniswap/src/features/telemetry/constants/extension' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { RPCType } from 'uniswap/src/types/chains' +import { logger } from 'utilities/src/logger/logger' +import { Keyring } from 'wallet/src/features/wallet/Keyring/Keyring' +import { walletContextValue } from 'wallet/src/features/wallet/context' + +const INACTIVITY_ALARM_NAME = 'inactivity' +// TODO(EXT-546): add a setting to turn off the auto-lock setting +const INACTIVITY_TIMEOUT_MINUTES = 60 * 24 // 1 day + +const windowIdToSidebarPortMap = new Map() +// TODO EXT-1020 add timeout support to avoid memory leaks +const windowIdToPendingRequestsMap = new Map() + +chrome.alarms.onAlarm.addListener(async (alarm) => { + if (alarm.name !== INACTIVITY_ALARM_NAME) { + return + } + + await lockWallet() +}) + +async function lockWallet(): Promise { + logger.debug('background', 'lockWallet', 'Locking wallet via background script') + sendAnalyticsEvent(ExtensionEventName.ChangeLockedState, { locked: true, location: 'background' }) + await Keyring.lock() +} + +chrome.runtime.onConnect.addListener(async (port) => { + await chrome.alarms.clear(INACTIVITY_ALARM_NAME) + + const windowId = port.name + const portChannel = createBackgroundToSidePanelMessagePort(port) + windowIdToSidebarPortMap.set(windowId, portChannel) + + const pendingRequests = windowIdToPendingRequestsMap.get(windowId) + + if (pendingRequests) { + for (const pendingRequest of pendingRequests) { + await portChannel.sendMessage(pendingRequest) + } + windowIdToPendingRequestsMap.delete(windowId) + } + + // Only gets called when `port.disconnect()` is called or `port.sendMessage()` for a disconnected port + port.onDisconnect.addListener(async () => { + windowIdToSidebarPortMap.delete(windowId) + + if (windowIdToSidebarPortMap.size <= 0) { + await chrome.alarms.create(INACTIVITY_ALARM_NAME, { + delayInMinutes: INACTIVITY_TIMEOUT_MINUTES, + }) + } + }) +}) + +let initialized = false +export function initMessageBridge(): void { + if (initialized) { + return + } + + contentScriptToBackgroundMessageChannel.addAllMessageListener(async (message, sender) => { + // The side panel needs to be opened here because it has to be in response to a user action. + // Further down in the chain it will be opened in response to a message from the background script. + + if (sender?.tab?.id === undefined || sender?.tab?.url === undefined) { + logger.error(new Error('sender.tab id or url is not defined'), { + tags: { + file: 'background/background.ts', + function: 'dappMessageListener', + }, + }) + return + } + + const senderTabInfo = { + id: sender.tab.id, + url: sender.tab.url, + favIconUrl: sender.tab.favIconUrl, + } + + const isSidebarActive = Boolean(windowIdToSidebarPortMap.get(sender.tab.windowId.toString())) + if (!isSidebarActive) { + const handled = handleSilentBackgroundRequest(message, senderTabInfo) + if (handled) { + return + } + } + + await handleSidebarRequest(message, sender.tab.windowId, senderTabInfo) + }) + + contentScriptUtilityMessageChannel.addMessageListener(ContentScriptUtilityMessageType.ErrorLog, async (message) => { + // Need to re-construct the error object from the message since the error object is not serializable + logger.error(new Error(message.message), { + tags: { + file: message.fileName, + function: message.functionName, + ...message.tags, + }, + }) + }) + + contentScriptUtilityMessageChannel.addMessageListener(ContentScriptUtilityMessageType.InfoLog, async (message) => { + logger.info(message.fileName, message.functionName, message.message, message.tags) + }) + + contentScriptUtilityMessageChannel.addMessageListener(ContentScriptUtilityMessageType.FocusOnboardingTab, () => { + focusOrCreateOnboardingTab().catch((error) => + logger.error(error, { + tags: { + file: 'backgroundDappRequests.ts', + function: 'contentScriptUtilityMessageListener', + }, + }), + ) + }) + contentScriptUtilityMessageChannel.addMessageListener(ContentScriptUtilityMessageType.FocusOnboardingTab, () => { + focusOrCreateOnboardingTab().catch((error) => + logger.error(error, { + tags: { + file: 'backgroundDappRequests.ts', + function: 'contentScriptUtilityMessageListener', + }, + }), + ) + }) + + initialized = true +} + +/** + * Dapp requests that should be silently handled by the background worker as a proxy if the sidebar is not open + * Avoids async to trigger open side panel as quickly as possible + * @returns true if the request was handled, false otherwise + */ +function handleSilentBackgroundRequest(request: DappRequest, senderTabInfo: SenderTabInfo): boolean { + const dappUrl = extractBaseUrl(senderTabInfo.url) + + if (!dappUrl) { + return false + } + + switch (request.type) { + case DappRequestType.ChangeChain: + handleChainChange(request, dappUrl, senderTabInfo.id).catch(() => {}) + return true + case DappRequestType.RevokePermissions: + handleRevokePermissions(request, dappUrl, senderTabInfo.id).catch(() => {}) + return true + default: + return false + } +} + +async function handleChainChange(request: ChangeChainRequest, dappUrl: string, tabId: number): Promise { + await dappStore.init() + const { activeConnectedAddress } = dappStore.getDappInfo(dappUrl) ?? {} + const updatedChainId = toSupportedChainId(hexadecimalStringToInt(request.chainId)) + const provider = updatedChainId ? walletContextValue.providers.getProvider(updatedChainId, RPCType.Public) : undefined + const response = changeChain({ + provider, + dappUrl, + updatedChainId, + requestId: request.requestId, + activeConnectedAddress, + }) + + await dappResponseMessageChannel.sendMessageToTab(tabId, response) +} + +async function handleRevokePermissions( + request: RevokePermissionsRequest, + dappUrl: string, + tabId: number, +): Promise { + await dappStore.init() + const revokedPermissions = Object.keys(request.permissions) + + if (revokedPermissions.includes(ExtensionEthMethods.eth_accounts)) { + await removeDappConnection(dappUrl) + await dappResponseMessageChannel.sendMessageToTab(tabId, { + type: DappResponseType.RevokePermissionsResponse, + requestId: request.requestId, + }) + } else { + await dappResponseMessageChannel.sendMessageToTab(tabId, { + type: DappResponseType.ErrorResponse, + error: serializeError(rpcErrors.methodNotFound()), + requestId: request.requestId, + }) + } +} + +class ExpectedNoPortError extends Error { + constructor() { + super('No port in storage to post message to') + } +} + +async function handleSidebarRequest( + request: DappRequest, + windowId: number, + senderTabInfo: DappRequestMessage['senderTabInfo'], +): Promise { + const windowIdString = windowId.toString() + const portChannel = windowIdToSidebarPortMap.get(windowIdString) + const message: DappRequestMessage = { + type: BackgroundToSidePanelRequestType.DappRequestReceived, + dappRequest: request, + senderTabInfo, + isSidebarClosed: !portChannel, + } + + try { + if (!portChannel) { + throw new ExpectedNoPortError() + } + + await portChannel.sendMessage(message) + } catch (error) { + await openSidePanel(senderTabInfo.id, windowId) + + windowIdToPendingRequestsMap.set(windowIdString, windowIdToPendingRequestsMap.get(windowIdString) ?? []) + windowIdToPendingRequestsMap.get(windowIdString)?.push(message) + + if (!(error instanceof ExpectedNoPortError)) { + logger.error(error, { + tags: { + file: 'backgroundDappRequests.ts', + function: 'handleSidebarRequest', + }, + }) + } + } +} diff --git a/apps/extension/src/background/backgroundStore.ts b/apps/extension/src/background/backgroundStore.ts new file mode 100644 index 00000000000..6664de5a803 --- /dev/null +++ b/apps/extension/src/background/backgroundStore.ts @@ -0,0 +1,71 @@ +import { readIsOnboardedFromStorage, readReduxStateFromStorage } from 'src/background/utils/persistedStateUtils' +import { WebState } from 'src/store/webReducer' +import { logger } from 'utilities/src/logger/logger' + +type BackgroundState = { + isOnboarded: boolean +} + +const state: BackgroundState = { + isOnboarded: false, +} + +type OnboardingChangedListener = (isOnboarded: boolean) => void +const onboardingChangedListeners: OnboardingChangedListener[] = [] + +// Allows for multiple init attempts from different sources +let initPromise: Promise | undefined + +async function init(): Promise { + if (!initPromise) { + initPromise = initInternal() + } + + return initPromise +} + +async function initInternal(): Promise { + try { + const reduxState = await readReduxStateFromStorage() + + if (!reduxState) { + logger.debug('backgroundStore.ts', 'initInternal', 'Failed to read redux state from storage') + } + + await updateFromReduxState(reduxState) + chrome.storage.local.onChanged.addListener(async (changes) => { + const newReduxState = await readReduxStateFromStorage(changes) + await updateFromReduxState(newReduxState) + }) + } catch (error) { + logger.error(error, { + tags: { + file: 'backgroundStore.ts', + function: 'init', + }, + }) + } +} + +async function updateFromReduxState(reduxState: WebState | undefined): Promise { + if (reduxState) { + updateIsOnboarded(await readIsOnboardedFromStorage()) // Can replace this with selector after migration is complete + } +} + +function updateIsOnboarded(isOnboarded: boolean): void { + if (isOnboarded !== state.isOnboarded) { + state.isOnboarded = isOnboarded + onboardingChangedListeners.forEach((listener) => listener(isOnboarded)) + } +} + +function addOnboardingChangedListener(listener: OnboardingChangedListener): void { + onboardingChangedListeners.push(listener) +} + +export const backgroundStore = { + state, + init, + addOnboardingChangedListener, +} diff --git a/apps/extension/src/background/messagePassing/messageChannels.ts b/apps/extension/src/background/messagePassing/messageChannels.ts new file mode 100644 index 00000000000..ffadf741f2f --- /dev/null +++ b/apps/extension/src/background/messagePassing/messageChannels.ts @@ -0,0 +1,339 @@ +import { + AccountResponse, + AccountResponseSchema, + ChainIdResponse, + ChainIdResponseSchema, + ChangeChainRequest, + ChangeChainRequestSchema, + ChangeChainResponse, + ChangeChainResponseSchema, + DappRequestType, + DappResponseType, + ErrorResponse, + ErrorResponseSchema, + GetAccountRequest, + GetAccountRequestSchema, + GetChainIdRequest, + GetChainIdRequestSchema, + GetPermissionsRequest, + GetPermissionsRequestSchema, + GetPermissionsResponse, + GetPermissionsResponseSchema, + RequestAccountRequest, + RequestAccountRequestSchema, + RequestPermissionsRequest, + RequestPermissionsRequestSchema, + RequestPermissionsResponse, + RequestPermissionsResponseSchema, + RevokePermissionsRequest, + RevokePermissionsRequestSchema, + RevokePermissionsResponse, + RevokePermissionsResponseSchema, + SendTransactionRequest, + SendTransactionRequestSchema, + SendTransactionResponse, + SendTransactionResponseSchema, + SignMessageRequest, + SignMessageRequestSchema, + SignMessageResponse, + SignMessageResponseSchema, + SignTransactionRequest, + SignTransactionRequestSchema, + SignTransactionResponse, + SignTransactionResponseSchema, + SignTypedDataRequest, + SignTypedDataRequestSchema, + SignTypedDataResponse, + SignTypedDataResponseSchema, + UniswapOpenSidebarRequest, + UniswapOpenSidebarRequestSchema, + UniswapOpenSidebarResponse, + UniswapOpenSidebarResponseSchema, +} from 'src/app/features/dappRequests/types/DappRequestTypes' +import { + MessageParsers, + TypedPortMessageChannel, + TypedRuntimeMessageChannel, +} from 'src/background/messagePassing/platform' +import { + HighlightOnboardingTabMessage, + HighlightOnboardingTabMessageSchema, + OnboardingMessageType, + SidebarOpenedMessage, + SidebarOpenedMessageSchema, +} from 'src/background/messagePassing/types/ExtensionMessages' +import { + BackgroundToSidePanelRequestType, + ContentScriptUtilityMessageType, + DappRequestMessage, + DappRequestMessageSchema, + ErrorLog, + ErrorLogSchema, + ExtensionChainChange, + ExtensionChainChangeSchema, + ExtensionToDappRequestType, + FocusOnboardingMessage, + FocusOnboardingMessageSchema, + InfoLog, + InfoLogSchema, + TabActivatedRequest, + TabActivatedRequestSchema, + UpdateConnectionRequest, + UpdateConnectionRequestSchema, +} from 'src/background/messagePassing/types/requests' + +export enum MessageChannelName { + DappContentScript = 'DappContentScript', + DappBackground = 'DappBackground', + DappResponse = 'DappResponse', + Onboarding = 'Onboarding', + ExternalDapp = 'ExternalDapp', + ContentScriptUtility = 'ContentScriptUtility', +} + +type OnboardingMessageSchemas = { + [OnboardingMessageType.HighlightOnboardingTab]: HighlightOnboardingTabMessage + [OnboardingMessageType.SidebarOpened]: SidebarOpenedMessage +} +const onboardingMessageParsers: MessageParsers = { + [OnboardingMessageType.HighlightOnboardingTab]: (message): HighlightOnboardingTabMessage => + HighlightOnboardingTabMessageSchema.parse(message), + [OnboardingMessageType.SidebarOpened]: (message): SidebarOpenedMessage => SidebarOpenedMessageSchema.parse(message), +} + +function createOnboardingMessageChannel(): TypedRuntimeMessageChannel { + return new TypedRuntimeMessageChannel({ + channelName: MessageChannelName.Onboarding, + messageParsers: onboardingMessageParsers, + }) +} + +export function createOnboardingMessagePort( + port: chrome.runtime.Port, +): TypedPortMessageChannel { + return new TypedPortMessageChannel({ + channelName: MessageChannelName.Onboarding, + messageParsers: onboardingMessageParsers, + port, + }) +} + +type BackgroundToSidePanelMessageSchemas = { + [BackgroundToSidePanelRequestType.DappRequestReceived]: DappRequestMessage + [BackgroundToSidePanelRequestType.TabActivated]: TabActivatedRequest +} +const backgroundToSidePanelMessageParsers: MessageParsers< + BackgroundToSidePanelRequestType, + BackgroundToSidePanelMessageSchemas +> = { + [BackgroundToSidePanelRequestType.DappRequestReceived]: (message): DappRequestMessage => + DappRequestMessageSchema.parse(message), + [BackgroundToSidePanelRequestType.TabActivated]: (message): TabActivatedRequest => + TabActivatedRequestSchema.parse(message), +} + +function createBackgroundToSidePanelMessageChannel(): TypedRuntimeMessageChannel< + BackgroundToSidePanelRequestType, + BackgroundToSidePanelMessageSchemas +> { + return new TypedRuntimeMessageChannel({ + channelName: MessageChannelName.DappBackground, + messageParsers: backgroundToSidePanelMessageParsers, + }) +} + +export function createBackgroundToSidePanelMessagePort( + port: chrome.runtime.Port, +): TypedPortMessageChannel { + return new TypedPortMessageChannel({ + channelName: MessageChannelName.DappBackground, + messageParsers: backgroundToSidePanelMessageParsers, + port, + }) +} + +type ContentScriptToBackgroundMessageSchemas = { + [DappRequestType.ChangeChain]: ChangeChainRequest + [DappRequestType.GetAccount]: GetAccountRequest + [DappRequestType.GetChainId]: GetChainIdRequest + [DappRequestType.GetPermissions]: GetPermissionsRequest + [DappRequestType.RequestAccount]: RequestAccountRequest + [DappRequestType.RequestPermissions]: RequestPermissionsRequest + [DappRequestType.RevokePermissions]: RevokePermissionsRequest + [DappRequestType.SendTransaction]: SendTransactionRequest + [DappRequestType.SignMessage]: SignMessageRequest + [DappRequestType.SignTransaction]: SignTransactionRequest + [DappRequestType.SignTypedData]: SignTypedDataRequest + [DappRequestType.UniswapOpenSidebar]: UniswapOpenSidebarRequest +} +const contentScriptToBackgroundMessageParsers: MessageParsers< + DappRequestType, + ContentScriptToBackgroundMessageSchemas +> = { + [DappRequestType.ChangeChain]: (message): ChangeChainRequest => ChangeChainRequestSchema.parse(message), + [DappRequestType.GetAccount]: (message): GetAccountRequest => GetAccountRequestSchema.parse(message), + [DappRequestType.GetChainId]: (message): GetChainIdRequest => GetChainIdRequestSchema.parse(message), + [DappRequestType.GetPermissions]: (message): GetPermissionsRequest => GetPermissionsRequestSchema.parse(message), + [DappRequestType.RequestAccount]: (message): RequestAccountRequest => RequestAccountRequestSchema.parse(message), + [DappRequestType.RequestPermissions]: (message): RequestPermissionsRequest => + RequestPermissionsRequestSchema.parse(message), + [DappRequestType.RevokePermissions]: (message): RevokePermissionsRequest => + RevokePermissionsRequestSchema.parse(message), + [DappRequestType.SendTransaction]: (message): SendTransactionRequest => SendTransactionRequestSchema.parse(message), + [DappRequestType.SignMessage]: (message): SignMessageRequest => SignMessageRequestSchema.parse(message), + [DappRequestType.SignTransaction]: (message): SignTransactionRequest => SignTransactionRequestSchema.parse(message), + [DappRequestType.SignTypedData]: (message): SignTypedDataRequest => SignTypedDataRequestSchema.parse(message), + [DappRequestType.UniswapOpenSidebar]: (message): UniswapOpenSidebarRequest => + UniswapOpenSidebarRequestSchema.parse(message), +} + +function createContentScriptToBackgroundMessageChannel(): TypedRuntimeMessageChannel< + DappRequestType, + ContentScriptToBackgroundMessageSchemas +> { + return new TypedRuntimeMessageChannel({ + channelName: MessageChannelName.DappContentScript, + messageParsers: contentScriptToBackgroundMessageParsers, + canReceiveFromContentScript: true, + }) +} + +export function createContentScriptToBackgroundMessagePort( + port: chrome.runtime.Port, +): TypedPortMessageChannel { + return new TypedPortMessageChannel({ + channelName: MessageChannelName.DappContentScript, + messageParsers: contentScriptToBackgroundMessageParsers, + canReceiveFromContentScript: true, + port, + }) +} + +type DappResponseMessageSchemas = { + [DappResponseType.AccountResponse]: AccountResponse + [DappResponseType.ChainChangeResponse]: ChangeChainResponse + [DappResponseType.ChainIdResponse]: ChainIdResponse + [DappResponseType.ErrorResponse]: ErrorResponse + [DappResponseType.GetPermissionsResponse]: GetPermissionsResponse + [DappResponseType.RequestPermissionsResponse]: RequestPermissionsResponse + [DappResponseType.RevokePermissionsResponse]: RevokePermissionsResponse + [DappResponseType.SendTransactionResponse]: SendTransactionResponse + [DappResponseType.SignMessageResponse]: SignMessageResponse + [DappResponseType.SignTransactionResponse]: SignTransactionResponse + [DappResponseType.SignTypedDataResponse]: SignTypedDataResponse + [DappResponseType.UniswapOpenSidebarResponse]: UniswapOpenSidebarResponse +} +const dappResponseMessageParsers: MessageParsers = { + [DappResponseType.AccountResponse]: (message): AccountResponse => AccountResponseSchema.parse(message), + [DappResponseType.ChainChangeResponse]: (message): ChangeChainResponse => ChangeChainResponseSchema.parse(message), + [DappResponseType.ChainIdResponse]: (message): ChainIdResponse => ChainIdResponseSchema.parse(message), + [DappResponseType.ErrorResponse]: (message): ErrorResponse => ErrorResponseSchema.parse(message), + [DappResponseType.GetPermissionsResponse]: (message): GetPermissionsResponse => + GetPermissionsResponseSchema.parse(message), + [DappResponseType.RequestPermissionsResponse]: (message): RequestPermissionsResponse => + RequestPermissionsResponseSchema.parse(message), + [DappResponseType.RevokePermissionsResponse]: (message): RevokePermissionsResponse => + RevokePermissionsResponseSchema.parse(message), + [DappResponseType.SendTransactionResponse]: (message): SendTransactionResponse => + SendTransactionResponseSchema.parse(message), + [DappResponseType.SignMessageResponse]: (message): SignMessageResponse => SignMessageResponseSchema.parse(message), + [DappResponseType.SignTransactionResponse]: (message): SignTransactionResponse => + SignTransactionResponseSchema.parse(message), + [DappResponseType.SignTypedDataResponse]: (message): SignTypedDataResponse => + SignTypedDataResponseSchema.parse(message), + [DappResponseType.UniswapOpenSidebarResponse]: (message): UniswapOpenSidebarResponse => + UniswapOpenSidebarResponseSchema.parse(message), +} + +function createDappResponseMessageChannel(): TypedRuntimeMessageChannel { + return new TypedRuntimeMessageChannel({ + channelName: MessageChannelName.DappResponse, + messageParsers: dappResponseMessageParsers, + }) +} + +export function createDappResponseMessagePort( + port: chrome.runtime.Port, +): TypedPortMessageChannel { + return new TypedPortMessageChannel({ + channelName: MessageChannelName.DappResponse, + messageParsers: dappResponseMessageParsers, + port, + }) +} + +type ExternalDappMessageSchemas = { + [ExtensionToDappRequestType.SwitchChain]: ExtensionChainChange + [ExtensionToDappRequestType.UpdateConnections]: UpdateConnectionRequest +} +const externalDappMessageParsers: MessageParsers = { + [ExtensionToDappRequestType.SwitchChain]: (message): ExtensionChainChange => + ExtensionChainChangeSchema.parse(message), + [ExtensionToDappRequestType.UpdateConnections]: (message): UpdateConnectionRequest => + UpdateConnectionRequestSchema.parse(message), +} + +export function createExternalDappMessageChannel(): TypedRuntimeMessageChannel< + ExtensionToDappRequestType, + ExternalDappMessageSchemas +> { + return new TypedRuntimeMessageChannel({ + channelName: MessageChannelName.ExternalDapp, + messageParsers: externalDappMessageParsers, + }) +} + +export function createExternalDappMessagePort( + port: chrome.runtime.Port, +): TypedPortMessageChannel { + return new TypedPortMessageChannel({ + channelName: MessageChannelName.ExternalDapp, + messageParsers: externalDappMessageParsers, + port, + }) +} + +type ContentScriptUtilityMessageSchemas = { + [ContentScriptUtilityMessageType.FocusOnboardingTab]: FocusOnboardingMessage + [ContentScriptUtilityMessageType.ErrorLog]: ErrorLog + [ContentScriptUtilityMessageType.InfoLog]: InfoLog +} +const contentScriptUtilityMessageParsers: MessageParsers< + ContentScriptUtilityMessageType, + ContentScriptUtilityMessageSchemas +> = { + [ContentScriptUtilityMessageType.FocusOnboardingTab]: (message): FocusOnboardingMessage => + FocusOnboardingMessageSchema.parse(message), + [ContentScriptUtilityMessageType.ErrorLog]: (message): ErrorLog => ErrorLogSchema.parse(message), + [ContentScriptUtilityMessageType.InfoLog]: (message): InfoLog => InfoLogSchema.parse(message), +} + +export function createContentScriptUtilityMessageChannel(): TypedRuntimeMessageChannel< + ContentScriptUtilityMessageType, + ContentScriptUtilityMessageSchemas +> { + return new TypedRuntimeMessageChannel({ + channelName: MessageChannelName.ContentScriptUtility, + messageParsers: contentScriptUtilityMessageParsers, + canReceiveFromContentScript: true, + }) +} + +export function createContentScriptUtilityMessagePort( + port: chrome.runtime.Port, +): TypedPortMessageChannel { + return new TypedPortMessageChannel({ + channelName: MessageChannelName.ExternalDapp, + messageParsers: contentScriptUtilityMessageParsers, + port, + }) +} + +export const onboardingMessageChannel = createOnboardingMessageChannel() +export const backgroundToSidePanelMessageChannel = createBackgroundToSidePanelMessageChannel() +export const contentScriptToBackgroundMessageChannel = createContentScriptToBackgroundMessageChannel() +export const dappResponseMessageChannel = createDappResponseMessageChannel() +export const externalDappMessageChannel = createExternalDappMessageChannel() +export const contentScriptUtilityMessageChannel = createContentScriptUtilityMessageChannel() + +export type DappBackgroundPortChannel = ReturnType diff --git a/apps/extension/src/background/messagePassing/messageTypes.ts b/apps/extension/src/background/messagePassing/messageTypes.ts new file mode 100644 index 00000000000..a73e41bb09b --- /dev/null +++ b/apps/extension/src/background/messagePassing/messageTypes.ts @@ -0,0 +1,7 @@ +import { z } from 'zod' + +// SCHEMAS +export const MessageSchema = z.object({}) + +// TYPES +export type Message = z.infer diff --git a/apps/extension/src/background/messagePassing/messageUtils.ts b/apps/extension/src/background/messagePassing/messageUtils.ts new file mode 100644 index 00000000000..86209df7fc6 --- /dev/null +++ b/apps/extension/src/background/messagePassing/messageUtils.ts @@ -0,0 +1,28 @@ +import { Message } from 'src/background/messagePassing/messageTypes' + +type MessageValidator = (message: unknown) => message is T + +type WindowMessageHandler = (message: T, source: MessageEventSource | null) => void +type InvalidWindowMessageHandler = (message: unknown, source?: MessageEventSource | null) => void + +// Message listener for chrome.window with validation logic. Used only to receive external messages from dapps. +export function addWindowMessageListener( + validator: MessageValidator, + handler: WindowMessageHandler, + invalidMessageHandler?: InvalidWindowMessageHandler, +): (event: MessageEvent) => void { + const listener = (event: MessageEvent): void => { + if (event.source !== window || !validator(event.data)) { + invalidMessageHandler?.(event.data, event.source) + return + } + + handler(event.data, event.source) + } + window.addEventListener('message', listener) + return listener +} + +export function removeWindowMessageListener(listener: (event: MessageEvent) => void): void { + window.removeEventListener('message', listener) +} diff --git a/apps/extension/src/background/messagePassing/platform.ts b/apps/extension/src/background/messagePassing/platform.ts new file mode 100644 index 00000000000..a7ab70c0d2e --- /dev/null +++ b/apps/extension/src/background/messagePassing/platform.ts @@ -0,0 +1,300 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { logger } from 'utilities/src/logger/logger' + +const EXTENSION_CONTEXT_INVALIDATED_CHROMIUM_ERROR = 'Extension context invalidated.' + +type MessageListener = (message: T, sender?: chrome.runtime.MessageSender) => void +class ChromeMessageChannel { + protected readonly channelName: string + readonly port?: chrome.runtime.Port + + protected listeners: MessageListener[] = [] + + constructor({ + channelName, + port, + canReceiveFromContentScript = false, + }: { + channelName: string + canReceiveFromContentScript?: boolean + port?: chrome.runtime.Port + }) { + this.channelName = channelName + this.port = port + + const mainListener: MessageListener = (message, sender) => { + const targetMessage = message[this.channelName] + + if (targetMessage !== undefined) { + if (sender?.tab !== undefined && !canReceiveFromContentScript) { + return + } + + if (sender?.id !== chrome.runtime.id && !this.port) { + return + } + + this.listeners.forEach((listener) => { + listener(targetMessage, sender) + }) + } + } + + if (this.port) { + this.port.onMessage.addListener((message, senderPort) => mainListener(message, senderPort.sender)) + } else { + // eslint-disable-next-line no-restricted-syntax + chrome.runtime.onMessage.addListener(mainListener) + } + + this.sendMessage = this.sendMessage.bind(this) + this.sendMessageToTab = this.sendMessageToTab.bind(this) + this.sendMessageToTabUrl = this.sendMessageToTabUrl.bind(this) + this.addMessageListener = this.addMessageListener.bind(this) + this.removeMessageListener = this.removeMessageListener.bind(this) + } + + async sendMessage(message: any): Promise { + if (this.port) { + this.port.postMessage({ [this.channelName]: message }) + } else { + // eslint-disable-next-line no-restricted-syntax + chrome.runtime.sendMessage({ [this.channelName]: message }).catch(() => {}) + } + } + + async sendMessageToTab(tabId: number, message: any): Promise { + // eslint-disable-next-line no-restricted-syntax + await chrome.tabs.sendMessage(tabId, { [this.channelName]: message }) + } + + async sendMessageToTabUrl(tabUrl: string, message: any): Promise { + const urlMatcher = `${tabUrl}/*` + const promises: Promise[] = [] + chrome.tabs.query({ url: urlMatcher }, (tabs) => { + tabs.forEach((tab) => { + if (tab?.id) { + promises.push( + // eslint-disable-next-line no-restricted-syntax + chrome.tabs.sendMessage(tab.id, { [this.channelName]: message }).catch(() => { + // Not logging error here because it is expected that inactive tabs will not be able to receive the message + }), + ) + } + }) + }) + return Promise.all(promises) + } + + addMessageListener(listener: MessageListener): () => void { + this.listeners.push(listener) + + return () => this.removeMessageListener(listener) + } + + removeMessageListener(listener: MessageListener): void { + this.listeners = this.listeners.filter((l) => l !== listener) + } +} + +export type MessageParsers = { + [key in T]: (message: unknown) => R[key] +} +abstract class TypedMessageChannel< + T extends string, + R extends { [key in T]: { type: key } }, + L extends { [key in T]: MessageListener } = { [key in T]: MessageListener }, +> { + private readonly chromeMessageChannel: ChromeMessageChannel + private readonly messageParsers: MessageParsers + private listeners = new Map() + + constructor({ + channelName, + port, + messageParsers, + canReceiveFromContentScript, + }: { + channelName: string + port?: chrome.runtime.Port + messageParsers: MessageParsers + canReceiveFromContentScript?: boolean + }) { + this.messageParsers = messageParsers + this.chromeMessageChannel = new ChromeMessageChannel({ + channelName, + port, + canReceiveFromContentScript, + }) + + this.chromeMessageChannel.addMessageListener((message, sender) => { + let type: T | undefined + try { + const processed = this.processMessage(message) + const messageParser = processed.messageParser + type = processed.type + + const parsed = messageParser(message) + this.listeners.get(type)?.forEach((listener) => { + listener(parsed, sender) + }) + } catch (error) { + logger.error( + new Error(`Error validating message. Possible type is ${type}`, { + cause: error, + }), + { + tags: { + file: 'platform.ts', + function: 'TypedMessageChannel.constructor', + }, + }, + ) + } + }) + + this.sendMessage = this.sendMessage.bind(this) + this.sendMessageToTab = this.sendMessageToTab.bind(this) + this.sendMessageToTabUrl = this.sendMessageToTabUrl.bind(this) + this.addMessageListener = this.addMessageListener.bind(this) + this.removeMessageListener = this.removeMessageListener.bind(this) + } + + private processMessage(message: any): { type: T; messageParser: (message: unknown) => R[T] } { + const type = message.type as Maybe + if (!type) { + throw new Error('No type provided on message') + } + + const messageParser = this.messageParsers[type] + if (!messageParser) { + throw new Error(`No message parser found for type ${type}`) + } + return { type, messageParser } + } + + async sendMessage(message: R[T1]): Promise { + const { type } = message + + try { + await this.chromeMessageChannel.sendMessage(message) + return true + } catch (error) { + const isExtensionInvalidatedError = + error instanceof Error && error.message === EXTENSION_CONTEXT_INVALIDATED_CHROMIUM_ERROR + logger.error( + new Error( + `${isExtensionInvalidatedError ? 'Please refresh the page. ' : ''}Error sending message for type ${type}`, + { cause: error }, + ), + { + tags: { + file: 'platform.ts', + function: 'TypedMessageChannel.sendMessage', + }, + }, + ) + return false + } + } + + async sendMessageToTab(tabId: number, message: R[T1]): Promise { + const { type } = message + + try { + await this.chromeMessageChannel.sendMessageToTab(tabId, message) + return true + } catch (error) { + logger.error(new Error(`Error sending message to tab for type ${type}`, { cause: error }), { + tags: { + file: 'platform.ts', + function: 'TypedMessageChannel.sendMessageToTab', + }, + }) + return false + } + } + + async sendMessageToTabUrl(tabUrl: string, message: R[T1]): Promise { + const { type } = message + + try { + await this.chromeMessageChannel.sendMessageToTabUrl(tabUrl, message) + return true + } catch (error) { + logger.error(new Error(`Error sending message to tab for type ${type}`, { cause: error }), { + tags: { + file: 'platform.ts', + function: 'TypedMessageChannel.sendMessageToTabUrl', + }, + }) + return false + } + } + + addMessageListener(type: T1, listener: L[T1]): () => void { + this.listeners.set(type, this.listeners.get(type) ?? []) + this.listeners.get(type)?.push(listener) + + return () => this.removeMessageListener(type, listener) + } + + addAllMessageListener(listener: MessageListener): () => void { + const removeListeners = Object.keys(this.messageParsers).map((type) => + this.addMessageListener(type as T, listener as L[T]), + ) + + return () => removeListeners.forEach((remove) => remove()) + } + + removeMessageListener(type: T, listener: L[T]): void { + this.listeners.set(type, this.listeners.get(type)?.filter((l) => l !== listener) ?? []) + } +} + +/** + * Type-safe message channel class used for communication. Intended for general global use, backed by chrome.runtime + */ +export class TypedRuntimeMessageChannel< + T extends string, + R extends { [key in T]: { type: key } }, + L extends { [key in T]: MessageListener } = { [key in T]: MessageListener }, +> extends TypedMessageChannel { + constructor({ + channelName, + messageParsers, + canReceiveFromContentScript, + }: { + channelName: string + messageParsers: MessageParsers + canReceiveFromContentScript?: boolean + }) { + super({ channelName, messageParsers, canReceiveFromContentScript }) + } +} + +/** + * Adaptation of TypedRuntimeMessageChannel used as a wrapper around chrome.runtime.Port + */ +export class TypedPortMessageChannel< + T extends string, + R extends { [key in T]: { type: key } }, + L extends { [key in T]: MessageListener } = { [key in T]: MessageListener }, +> extends TypedMessageChannel { + readonly port: chrome.runtime.Port + + constructor({ + channelName, + messageParsers, + port, + canReceiveFromContentScript, + }: { + channelName: string + messageParsers: MessageParsers + port: chrome.runtime.Port + canReceiveFromContentScript?: boolean + }) { + super({ channelName, messageParsers, port, canReceiveFromContentScript }) + this.port = port + } +} diff --git a/apps/extension/src/background/messagePassing/types/ExtensionMessages.ts b/apps/extension/src/background/messagePassing/types/ExtensionMessages.ts new file mode 100644 index 00000000000..66bb0d00891 --- /dev/null +++ b/apps/extension/src/background/messagePassing/types/ExtensionMessages.ts @@ -0,0 +1,17 @@ +import { MessageSchema } from 'src/background/messagePassing/messageTypes' +import { z } from 'zod' + +export enum OnboardingMessageType { + HighlightOnboardingTab = 'HighlightOnboardingTab', + SidebarOpened = 'SidebarOpened', +} + +export const HighlightOnboardingTabMessageSchema = MessageSchema.extend({ + type: z.literal(OnboardingMessageType.HighlightOnboardingTab), +}) +export type HighlightOnboardingTabMessage = z.infer + +export const SidebarOpenedMessageSchema = MessageSchema.extend({ + type: z.literal(OnboardingMessageType.SidebarOpened), +}) +export type SidebarOpenedMessage = z.infer diff --git a/apps/extension/src/background/messagePassing/types/requests.ts b/apps/extension/src/background/messagePassing/types/requests.ts new file mode 100644 index 00000000000..f9ac4e49b62 --- /dev/null +++ b/apps/extension/src/background/messagePassing/types/requests.ts @@ -0,0 +1,94 @@ +import { DappRequestSchema } from 'src/app/features/dappRequests/types/DappRequestTypes' +import { MessageSchema } from 'src/background/messagePassing/messageTypes' +import { z } from 'zod' + +// ENUMS + +// Requests from content scripts to the extension (non-dapp requests) +export enum ContentScriptUtilityMessageType { + FocusOnboardingTab = 'FocusOnboardingTab', + ErrorLog = 'Error', + InfoLog = 'Info', +} + +export const ErrorLogSchema = MessageSchema.extend({ + type: z.literal(ContentScriptUtilityMessageType.ErrorLog), + message: z.string(), + fileName: z.string(), + functionName: z.string(), + tags: z.record(z.string()).optional(), +}) +export type ErrorLog = z.infer + +export const InfoLogSchema = MessageSchema.extend({ + type: z.literal(ContentScriptUtilityMessageType.InfoLog), + fileName: z.string(), + functionName: z.string(), + message: z.string(), + tags: z.record(z.string()), +}) +export type InfoLog = z.infer + +export const FocusOnboardingMessageSchema = MessageSchema.extend({ + type: z.literal(ContentScriptUtilityMessageType.FocusOnboardingTab), +}) +export type FocusOnboardingMessage = z.infer + +// Requests from background script to the extension sidebar +export enum BackgroundToSidePanelRequestType { + TabActivated = 'TabActivated', + DappRequestReceived = 'DappRequestReceived', +} + +export const DappRequestMessageSchema = z.object({ + type: z.literal(BackgroundToSidePanelRequestType.DappRequestReceived), + dappRequest: DappRequestSchema, + senderTabInfo: z.object({ + id: z.number(), + url: z.string(), + favIconUrl: z.string().optional(), + }), + isSidebarClosed: z.optional(z.boolean()), +}) +export type DappRequestMessage = z.infer + +export const TabActivatedRequestSchema = MessageSchema.extend({ + type: z.literal(BackgroundToSidePanelRequestType.TabActivated), +}) +export type TabActivatedRequest = z.infer + +// Requests outgoing from the extension to the injected script +export enum ExtensionToDappRequestType { + UpdateConnections = 'UpdateConnections', + SwitchChain = 'SwitchChain', +} + +const BaseExtensionRequestSchema = MessageSchema.extend({ + type: z.nativeEnum(ExtensionToDappRequestType), +}) +export type BaseExtensionRequest = z.infer + +export const ExtensionChainChangeSchema = BaseExtensionRequestSchema.extend({ + type: z.literal(ExtensionToDappRequestType.SwitchChain), + chainId: z.string(), + providerUrl: z.string(), +}) +export type ExtensionChainChange = z.infer + +export const UpdateConnectionRequestSchema = BaseExtensionRequestSchema.extend({ + type: z.literal(ExtensionToDappRequestType.UpdateConnections), + addresses: z.array(z.string()), // TODO (Thomas): Figure out what to do for type safety here +}) +export type UpdateConnectionRequest = z.infer + +export const ExtensionToDappRequestSchema = z.union([ + ExtensionChainChangeSchema, + UpdateConnectionRequestSchema, +]) +export type ExtensionToDappRequest = z.infer + +// VALIDATORS + +export function isValidExtensionToDappRequest(request: unknown): request is ExtensionToDappRequest { + return ExtensionToDappRequestSchema.safeParse(request).success +} diff --git a/apps/extension/src/background/utils/chromeSidePanelUtils.ts b/apps/extension/src/background/utils/chromeSidePanelUtils.ts new file mode 100644 index 00000000000..c566362777e --- /dev/null +++ b/apps/extension/src/background/utils/chromeSidePanelUtils.ts @@ -0,0 +1,44 @@ +import { logger } from 'utilities/src/logger/logger' + +export async function openSidePanel(tabId: number | undefined, windowId: number): Promise { + try { + // eslint-disable-next-line security/detect-non-literal-fs-filename + await chrome.sidePanel.open({ + tabId, + windowId, + }) + } catch (error) { + logger.error(error, { + tags: { + file: 'background/background.ts', + function: 'openSidebar', + }, + }) + } +} + +export async function setSidePanelBehavior(behavior: chrome.sidePanel.PanelBehavior): Promise { + try { + await chrome.sidePanel.setPanelBehavior(behavior) + } catch (error) { + logger.error(error, { + tags: { + file: 'background/background.ts', + function: 'setSideBarBehavior', + }, + }) + } +} + +export async function setSidePanelOptions(options: chrome.sidePanel.PanelOptions): Promise { + try { + await chrome.sidePanel.setOptions(options) + } catch (error) { + logger.error(error, { + tags: { + file: 'background/background.ts', + function: 'setSideBarOptions', + }, + }) + } +} diff --git a/apps/extension/src/background/utils/getCalldataInfoFromTransaction.ts b/apps/extension/src/background/utils/getCalldataInfoFromTransaction.ts new file mode 100644 index 00000000000..338f3a4a545 --- /dev/null +++ b/apps/extension/src/background/utils/getCalldataInfoFromTransaction.ts @@ -0,0 +1,63 @@ +import { parseCalldata as parseURCalldata } from 'src/app/features/dappRequests/requestContent/EthSend/Swap/universalRouter' +import { EthSendTransactionRPCActions } from 'src/app/features/dappRequests/types/DappRequestTypes' +import { EthersTransactionRequest } from 'src/app/features/dappRequests/types/EthersTypes' +import { parseCalldata as parseNfPMCalldata } from 'src/app/features/dappRequests/types/NonfungiblePositionManager' +import { NonfungiblePositionManagerCall } from 'src/app/features/dappRequests/types/NonfungiblePositionManagerTypes' +import { UniversalRouterCall } from 'src/app/features/dappRequests/types/UniversalRouterTypes' +import methodHashToFunctionSignature from 'utilities/src/calldata/methodHashToFunctionSignature' +import noop from 'utilities/src/react/noop' + +interface GetCalldataInfoFromTransactionReturnValue { + functionSignature: string | undefined + contractInteractions: EthSendTransactionRPCActions + to: string | undefined + parsedCalldata?: UniversalRouterCall | NonfungiblePositionManagerCall +} + +function getCalldataInfoFromTransaction( + transaction: EthersTransactionRequest, +): GetCalldataInfoFromTransactionReturnValue { + const calldataMethodHash = transaction.data.substring(2, 10) + const functionSignature = methodHashToFunctionSignature(calldataMethodHash) + const contractInteractions = EthSendTransactionRPCActions.ContractInteraction + const result: GetCalldataInfoFromTransactionReturnValue = { + functionSignature, + contractInteractions, + to: transaction.to, + } + + if (functionSignature) { + if (['approve', 'permit'].some((el) => functionSignature.includes(el))) { + result.contractInteractions = EthSendTransactionRPCActions.Approve + return result + } + try { + const URCalldata = parseURCalldata(transaction.data) + if (URCalldata) { + result.contractInteractions = EthSendTransactionRPCActions.Swap + result.parsedCalldata = URCalldata + return result + } + } catch (_e) { + noop() + } + try { + const NfPMCalldata = parseNfPMCalldata(transaction.data) + + if (NfPMCalldata) { + result.contractInteractions = EthSendTransactionRPCActions.LP + result.parsedCalldata = NfPMCalldata + return result + } + } catch (_e) { + noop() + } + if (functionSignature.includes('wrap')) { + result.contractInteractions = EthSendTransactionRPCActions.Wrap + return result + } + } + return result +} + +export default getCalldataInfoFromTransaction diff --git a/apps/extension/src/background/utils/loggerMiddleware.ts b/apps/extension/src/background/utils/loggerMiddleware.ts new file mode 100644 index 00000000000..b334591de1f --- /dev/null +++ b/apps/extension/src/background/utils/loggerMiddleware.ts @@ -0,0 +1,6 @@ +import { createLogger } from 'redux-logger' + +export const loggerMiddleware = createLogger({ + collapsed: true, + diff: true, +}) diff --git a/apps/extension/src/background/utils/persistedStateUtils.ts b/apps/extension/src/background/utils/persistedStateUtils.ts new file mode 100644 index 00000000000..8ebf4a471dd --- /dev/null +++ b/apps/extension/src/background/utils/persistedStateUtils.ts @@ -0,0 +1,39 @@ +import { isOnboardedSelector } from 'src/app/utils/isOnboardedSelector' +import { STATE_STORAGE_KEY } from 'src/store/constants' +import { readDeprecatedReduxedChromeStorage } from 'src/store/reduxedChromeStorageToReduxPersistMigration' +import { WebState } from 'src/store/webReducer' + +export async function readReduxStateFromStorage(storageChanges?: { + [key: string]: chrome.storage.StorageChange +}): Promise { + const root = storageChanges + ? storageChanges[STATE_STORAGE_KEY]?.newValue + : (await chrome.storage.local.get(STATE_STORAGE_KEY))[STATE_STORAGE_KEY] + + if (!root) { + return undefined + } + + const rootParsed = JSON.parse(root) + + Object.keys(rootParsed).forEach((key) => { + // Each reducer must be parsed individually. + rootParsed[key] = JSON.parse(rootParsed[key]) + }) + + return rootParsed as WebState +} + +export async function readIsOnboardedFromStorage(): Promise { + // The migration will happen in the sidebar, not in the background script, + // because the background script never persists the state (only reads it). + // So we need to check both the old and new storage keys to avoid the onboarding + // flow re-opening the first time the migration needs to run. + const [oldReduxedChromeStorageState, newReduxPersistState] = await Promise.all([ + readDeprecatedReduxedChromeStorage(), + readReduxStateFromStorage(), + ]) + + const state = oldReduxedChromeStorageState ?? newReduxPersistState + return state ? isOnboardedSelector(state) : false +} diff --git a/apps/extension/src/contentScript/WindowEthereumProxy.ts b/apps/extension/src/contentScript/WindowEthereumProxy.ts new file mode 100644 index 00000000000..2c479f5021d --- /dev/null +++ b/apps/extension/src/contentScript/WindowEthereumProxy.ts @@ -0,0 +1,160 @@ +import { rpcErrors, serializeError } from '@metamask/rpc-errors' +import EventEmitter from 'eventemitter3' +import { addWindowMessageListener, removeWindowMessageListener } from 'src/background/messagePassing/messageUtils' +import { BaseEthereumRequest, BaseEthereumRequestSchema } from 'src/contentScript/WindowEthereumRequestTypes' +import { ExtensionResponse, isValidExtensionResponse } from 'src/contentScript/types' +import { logger } from 'utilities/src/logger/logger' +import { v4 as uuidv4 } from 'uuid' +import { ZodError } from 'zod' + +type EthersSendCallback = (error: unknown, response: unknown) => void +type RequestInput = BaseEthereumRequest & { id?: number; jsonrpc?: string } + +const messages = { + errors: { + disconnected: (): string => 'Uniswap Wallet: Disconnected from chain. Attempting to connect.', + invalidRequestArgs: (): string => `Uniswap Wallet: Expected a single, non-array, object argument.`, + invalidRequestGeneric: (): string => `Uniswap Wallet: Please check the input passed to the request method`, + }, +} + +/** + * Proxy class that is injected at `window.ethereum` to handle all RPC and extension API requests. + * Passes along requests to the content script which then forwards and listens for requests accordingly. + */ +export class WindowEthereumProxy extends EventEmitter { + /** + * Boolean indicating that the provider is Uniswap Wallet. + */ + isUniswapWallet = true + + /** + * Boolean to spoof MetaMask + * TODO(EXT-393): Remove this once more dapps support EIP-6963 or have explicit support for Uniswap Wallet. + */ + isMetaMask: boolean + + /** + * Pending requests are stored as promises that resolve or reject based on the response from the content script. + */ + pendingRequests: { + [key: string]: { + resolve: (value: unknown) => void + reject: (error: unknown) => void + } + } + + constructor() { + super() + + this.isMetaMask = true + this.pendingRequests = {} + } + + // Deprecated EIP-11193 method + enable = async (): Promise => { + return this.request({ method: 'eth_requestAccounts' }) + } + + // Deprecated EIP-1193 method + send = ( + methodOrRequest: string | BaseEthereumRequest, + paramsOrCallback: Array | EthersSendCallback, + ): Promise | void => { + if (typeof methodOrRequest === 'string' && typeof paramsOrCallback !== 'function') { + return this.request({ + method: methodOrRequest, + params: paramsOrCallback, + }) + } else if (typeof methodOrRequest === 'object' && typeof paramsOrCallback === 'function') { + return this.sendAsync(methodOrRequest, paramsOrCallback) + } + return Promise.reject(new Error('Unsupported function parameters')) + } + + // Deprecated EIP-1193 method still in use by some DApps + sendAsync = ( + request: RequestInput, + callback: (error: unknown, response: unknown) => void, + ): Promise | void => { + return this.request(request).then( + (response) => + callback(null, { + result: response, + id: request.id, + jsonrpc: request.jsonrpc, + }), + (error) => callback(error, null), + ) + } + + request = async (args: RequestInput): Promise => { + return new Promise((resolve, reject) => { + try { + const ethereumRequest = BaseEthereumRequestSchema.parse(args) + + // Generate a unique ID for this request and store the promise callbacks + const requestId = uuidv4() + this.pendingRequests[requestId] = { resolve, reject } + const responseListener = addWindowMessageListener(isValidExtensionResponse, (response) => { + if (response.requestId === requestId) { + this.handleResponse(response) + removeWindowMessageListener(responseListener) + } + }) + window.postMessage({ + ...ethereumRequest, + requestId, + }) + } catch (error) { + logger.info('WindowEthereumProxy.ts', 'request', 'Invalid request', args) + + // Based on the zod error, we can determine the type of error and reject accordingly + if (error instanceof ZodError) { + return reject( + serializeError( + rpcErrors.invalidRequest({ + message: messages.errors.invalidRequestArgs(), + data: args, + }), + ), + ) + } + + return reject( + serializeError( + rpcErrors.invalidRequest({ + message: messages.errors.invalidRequestGeneric(), + data: args, + }), + ), + ) + } + }) + } + + private handleResponse(response: ExtensionResponse): boolean { + const { requestId, result, error } = response + const promise = this.pendingRequests[requestId] + if (!promise) { + logger.debug('WindowEthereumProxy.ts', 'handleResponse', 'No promise found for request id:', requestId) + return false + } + + if (error) { + promise.reject(error) + delete this.pendingRequests[requestId] + return true + } + + promise.resolve(result) + + // Clean up after handling the response + delete this.pendingRequests[requestId] + return true + } + // Utility function representing connectivity status for RPC requests to the current chain (as opposed to user accounts). + // Method itself created by MetaMask and not in EIP spec. Necessary since some dapps supporting EIP-6963 require it. + // TODO(EXT-1255): Currently faking real status, replace with actual implementation + isConnected = (): boolean => true +} diff --git a/apps/extension/src/contentScript/WindowEthereumRequestTypes.ts b/apps/extension/src/contentScript/WindowEthereumRequestTypes.ts new file mode 100644 index 00000000000..7e97b6c55d5 --- /dev/null +++ b/apps/extension/src/contentScript/WindowEthereumRequestTypes.ts @@ -0,0 +1,323 @@ +import { ethers } from 'ethers' +import { EthersTransactionRequestSchema } from 'src/app/features/dappRequests/types/EthersTypes' +import { HexadecimalNumberSchema } from 'src/app/features/dappRequests/types/utilityTypes' +import { HomeTabs } from 'src/app/navigation/constants' +import { ZodIssueCode, z } from 'zod' + +/** + * Schemas + types for requests that come via `window.ethereum.request` + * e.g.: {"jsonrpc":"2.0","method":"personal_sign","params": ["0x295a70b2de5e3953354a6a8344e616ed314d7251", "0xasdfasdfasdfasdfasdfasdfa"],"id":1}' + * @see https://eips.ethereum.org/EIPS/eip-1193 + * @see https://docs.metamask.io/guide/ethereum-provider.html#ethereum-request + * @see https://docs.metamask.io/wallet/reference/json-rpc-api/ + * + * Note: Our schemas include transformations to make it easier to work with the data + */ + +export const BaseEthereumRequestSchema = z.object({ + method: z.string(), + params: z.union([z.array(z.unknown()), z.record(z.string(), z.unknown())]).optional(), +}) + +export const EthereumRequestWithIdSchema = BaseEthereumRequestSchema.extend({ + requestId: z.string(), +}) +export type EthereumRequestWithId = z.infer + +export type BaseEthereumRequest = z.infer + +export const EthChainIdRequestSchema = EthereumRequestWithIdSchema.extend({ + method: z.literal('eth_chainId'), +}) +export type EthChainIdRequest = z.infer + +export const EthRequestAccountsRequestSchema = EthereumRequestWithIdSchema.extend({ + method: z.literal('eth_requestAccounts'), +}) +export type EthRequestAccountsRequest = z.infer + +export const EthAccountsRequestSchema = EthereumRequestWithIdSchema.extend({ + method: z.literal('eth_accounts'), +}) +export type EthAccountsRequest = z.infer +export const EthSendTransactionRequestSchema = EthereumRequestWithIdSchema.extend({ + requestId: z.string(), + method: z.literal('eth_sendTransaction'), + params: z.array(z.unknown()), +}).transform((data) => { + const { requestId, method, params } = data + if (params.length < 1) { + throw new Error('Params array must contain at least one element') + } + + const parseResult = EthersTransactionRequestSchema.safeParse(params[0]) + + if (!parseResult.success) { + throw new Error('First element of the array must match EthersTransactionRequestSchema') + } + + const transaction = parseResult.data + + return { + requestId, + method, + params, + transaction, + } +}) +export type EthSendTransactionRequest = z.infer + +export const PersonalSignRequestSchema = EthereumRequestWithIdSchema.extend({ + method: z.literal('personal_sign'), + params: z.array(z.unknown()), +}).transform((data) => { + const { requestId, method, params } = data + + if (params.length < 2) { + throw new z.ZodError([ + { + message: 'Params array must contain at least two elements', + path: ['params'], + code: ZodIssueCode.custom, + }, + ]) + } + + const messageHex = z.string().parse(params[0]) + + try { + ethers.utils.toUtf8String(messageHex) + } catch { + throw new z.ZodError([ + { + message: 'Message hex is not a valid hex string', + path: ['params', 'hexMessage'], + code: ZodIssueCode.custom, + }, + ]) + } + + const address = z.string().parse(params[1]) + + return { + requestId, + method, + params, + messageHex, + address, + } +}) + +export type PersonalSignRequest = z.infer + +export const EthSignTransactionRequestSchema = EthereumRequestWithIdSchema.extend({ + method: z.literal('eth_signTransaction'), + params: z.array(z.unknown()), +}).transform((data) => { + const { requestId, method, params } = data + + if (params.length < 1) { + throw new z.ZodError([ + { + message: 'Params array must contain at least one element', + path: ['params'], + code: ZodIssueCode.custom, + }, + ]) + } + + const parseResult = EthersTransactionRequestSchema.safeParse(params[0]) + if (!parseResult.success) { + throw new z.ZodError([ + { + message: 'First element of the array must match EthersTransactionRequestSchema', + path: ['params', '0'], + code: ZodIssueCode.custom, + }, + ]) + } + const transaction = parseResult.data + + return { + requestId, + method, + params, + transaction, + } +}) +export type EthSignTransactionRequest = z.infer + +export const EthSignTypedDataV4RequestSchema = EthereumRequestWithIdSchema.extend({ + method: z.literal('eth_signTypedData_v4'), + params: z.array(z.unknown()), +}).transform((data) => { + const { requestId, method, params } = data + + if (params.length < 2) { + throw new z.ZodError([ + { + message: 'Params array must contain at least two elements', + path: ['params'], + code: ZodIssueCode.custom, + }, + ]) + } + + const address = z.string().parse(params[0]) + const typedData = z.string().parse(params[1]) + + const chainId = JSON.parse(typedData)?.domain?.chainId + const formattedChainId = HexadecimalNumberSchema.parse(chainId) + if (!formattedChainId) { + throw new z.ZodError([ + { + message: 'Typed data must contain a chainId', + path: ['params', '1'], + code: ZodIssueCode.custom, + }, + ]) + } + return { + requestId, + method, + params, + address, + typedData, + } +}) +export type EthSignTypedDataV4Request = z.infer + +export const SwitchEthereumChainParameterSchema = z.object({ + chainId: z.string(), +}) + +export const WalletSwitchEthereumChainRequestSchema = EthereumRequestWithIdSchema.extend({ + method: z.literal('wallet_switchEthereumChain'), + params: z.array(z.unknown()), +}).transform((data) => { + const { requestId, method, params } = data + if (params.length < 1) { + throw new z.ZodError([ + { + message: 'Params array must contain at least one element', + path: ['params'], + code: ZodIssueCode.custom, + }, + ]) + } + + const parseResult = SwitchEthereumChainParameterSchema.safeParse(params[0]) + if (!parseResult.success) { + throw new z.ZodError([ + { + message: 'Chain id should be specified as a hexadecimal string within object', + path: ['params', '0'], + code: ZodIssueCode.custom, + }, + ]) + } + + const { chainId } = parseResult.data + + return { + requestId, + method, + params, + chainId, + } +}) +export type WalletSwitchEthereumChainRequest = z.infer + +// eslint-disable-next-line no-restricted-syntax +export const PermissionRequestSchema = z.record(z.record(z.any())) + +export const RequestedPermissionSchema = z.object({ + parentCapability: z.string(), // name of the method for which the permission is requested + date: z.number().optional(), // in UNIX time +}) + +export const CaveatSchema = z.object({ + type: z.string(), + // eslint-disable-next-line no-restricted-syntax + value: z.any(), +}) +export type Caveat = z.infer + +export const PermissionSchema = z.object({ + invoker: z.string(), + parentCapability: z.string(), + caveats: z.array(CaveatSchema), +}) +export type Permission = z.infer + +export const WalletRequestPermissionsRequestSchema = EthereumRequestWithIdSchema.extend({ + method: z.literal('wallet_requestPermissions'), + params: z.array(z.unknown()), +}).transform((data) => { + const { requestId, method, params } = data + if (params.length < 1) { + throw new z.ZodError([ + { + message: 'Params array must contain at least one element', + path: ['params'], + code: ZodIssueCode.custom, + }, + ]) + } + + const permissions = PermissionRequestSchema.parse(params[0]) + + return { + requestId, + method, + params, + permissions, + } +}) + +export type WalletRequestPermissionsRequest = z.infer + +export const WalletRevokePermissionsRequestSchema = EthereumRequestWithIdSchema.extend({ + method: z.literal('wallet_revokePermissions'), + params: z.array(z.unknown()), +}).transform((data) => { + const { requestId, method, params } = data + if (params.length < 1) { + throw new z.ZodError([ + { + message: 'Params array must contain at least one element', + path: ['params'], + code: ZodIssueCode.custom, + }, + ]) + } + + const permissions = PermissionRequestSchema.parse(params[0]) + + return { + requestId, + method, + params, + permissions, + } +}) + +export type WalletRevokePermissionsRequest = z.infer + +export const WalletGetPermissionsRequestSchema = EthereumRequestWithIdSchema.extend({ + method: z.literal('wallet_getPermissions'), +}) +export type WalletGetPermissionsRequest = z.infer + +export const UniswapOpenSidebarRequestSchema = EthereumRequestWithIdSchema.extend({ + method: z.literal('uniswap_openSidebar'), + params: z.array(z.unknown()), +}).transform((data) => { + const tab = z.nativeEnum(HomeTabs).optional().parse(data.params[0]) + return { + ...data, + tab, + } +}) + +export type UniswapOpenSidebarRequest = z.infer diff --git a/apps/extension/src/contentScript/ethereum.ts b/apps/extension/src/contentScript/ethereum.ts new file mode 100644 index 00000000000..7864cb1953f --- /dev/null +++ b/apps/extension/src/contentScript/ethereum.ts @@ -0,0 +1,84 @@ +import { addWindowMessageListener } from 'src/background/messagePassing/messageUtils' +import { WindowEthereumProxy } from 'src/contentScript/WindowEthereumProxy' +import { isValidContentScriptToProxyEmission } from 'src/contentScript/types' +import { logger } from 'utilities/src/logger/logger' +import { v4 as uuid } from 'uuid' + +// TODO(xtine): Get this working by importing the svg file directly. The svg text comes from packages/ui/src/assets/icons/uniswap-logo.svg +const UNISWAP_LOGO = `data:image/svg+xml,${encodeURIComponent(` + + + + + + + + + + + + +`)}` +const UNISWAP_NAME = 'Uniswap Extension' +const UNISWAP_RDNS = 'org.uniswap.app' + +declare global { + interface Window { + isStretchInstalled?: boolean + ethereum?: WindowEthereumProxy + } +} + +enum EIP6963EventNames { + Announce = 'eip6963:announceProvider', + Request = 'eip6963:requestProvider', +} + +interface EIP6963ProviderInfo { + uuid: string + name: string + icon: string + rdns: string +} + +const uniswapProvider = new WindowEthereumProxy() +window.ethereum = uniswapProvider + +addWindowMessageListener(isValidContentScriptToProxyEmission, (message) => { + logger.debug('ethereum.ts', `Emitting ${message.emitKey} via WindowEthereumProxy`, message.emitValue) + uniswapProvider.emit(message.emitKey, message.emitValue) +}) +function announceProvider(): void { + const info: EIP6963ProviderInfo = { + uuid: uuid(), + name: UNISWAP_NAME, + icon: UNISWAP_LOGO, + rdns: UNISWAP_RDNS, + } + + window.dispatchEvent( + new CustomEvent(EIP6963EventNames.Announce, { + detail: Object.freeze({ info, provider: uniswapProvider }), + }), + ) +} + +window.addEventListener(EIP6963EventNames.Request, (event) => { + if (!isValidRequestProviderEvent(event)) { + throw new Error( + `Invalid EIP-6963 RequestProviderEvent object received from ${EIP6963EventNames.Request} event. See https://eips.ethereum.org/EIPS/eip-6963 for requirements.`, + ) + } + + announceProvider() +}) + +announceProvider() + +type EIP6963RequestProviderEvent = Event & { + type: EIP6963EventNames.Request +} + +function isValidRequestProviderEvent(event: unknown): event is EIP6963RequestProviderEvent { + return event instanceof Event && event.type === EIP6963EventNames.Request +} diff --git a/apps/extension/src/contentScript/index.tsx b/apps/extension/src/contentScript/index.tsx new file mode 100644 index 00000000000..96d0a1de30d --- /dev/null +++ b/apps/extension/src/contentScript/index.tsx @@ -0,0 +1,9 @@ +import React from 'react' +import { createRoot } from 'react-dom/client' + +const container = document.createElement('div') +container.id = 'crx-root' +document.body.append(container) + +const root = createRoot(container) +root.render() diff --git a/apps/extension/src/contentScript/injected.test.ts b/apps/extension/src/contentScript/injected.test.ts new file mode 100644 index 00000000000..859888dd8a8 --- /dev/null +++ b/apps/extension/src/contentScript/injected.test.ts @@ -0,0 +1,11 @@ +jest.mock('src/background/messagePassing/messageChannels') + +describe('injected', () => { + it('should run without throwing an error', () => { + // This does not exist in the extension execution environment for content scripts + Object.defineProperty(document, 'head', { value: undefined, writable: true }) + + const injected = require('./injected') + expect(injected).toBeTruthy() + }) +}) diff --git a/apps/extension/src/contentScript/injected.ts b/apps/extension/src/contentScript/injected.ts new file mode 100644 index 00000000000..85ad15cc1cf --- /dev/null +++ b/apps/extension/src/contentScript/injected.ts @@ -0,0 +1,267 @@ +import { JsonRpcProvider } from '@ethersproject/providers' +import { providerErrors, serializeError } from '@metamask/rpc-errors' +import { dappStore } from 'src/app/features/dapp/store' +import { getOrderedConnectedAddresses } from 'src/app/features/dapp/utils' +import { backgroundStore } from 'src/background/backgroundStore' +import { + contentScriptUtilityMessageChannel, + externalDappMessageChannel, +} from 'src/background/messagePassing/messageChannels' +import { addWindowMessageListener } from 'src/background/messagePassing/messageUtils' +import { + ContentScriptUtilityMessageType, + ErrorLog, + ExtensionToDappRequestType, + InfoLog, +} from 'src/background/messagePassing/types/requests' +import { ExtensionEthMethodHandler } from 'src/contentScript/methodHandlers/ExtensionEthMethodHandler' +import { ProviderDirectMethodHandler } from 'src/contentScript/methodHandlers/ProviderDirectMethodHandler' +import { UniswapMethodHandler } from 'src/contentScript/methodHandlers/UniswapMethodHandler' +import { emitAccountsChanged, emitChainChanged } from 'src/contentScript/methodHandlers/emitUtils' +import { ExtensionEthMethods } from 'src/contentScript/methodHandlers/requestMethods' +import { + isDeprecatedMethod, + isExtensionEthMethod, + isProviderDirectMethod, + isUniswapMethod, + isUnsupportedMethod, + postDeprecatedMethodError, + postParsingError, + postUnknownMethodError, +} from 'src/contentScript/methodHandlers/utils' +import { WindowEthereumRequest, isValidWindowEthereumRequest } from 'src/contentScript/types' +import { chainIdToHexadecimalString } from 'uniswap/src/features/chains/utils' +import { RPCType } from 'uniswap/src/types/chains' +import { logger } from 'utilities/src/logger/logger' +import { arraysAreEqual } from 'utilities/src/primitives/array' +import { walletContextValue } from 'wallet/src/features/wallet/context' + +import { getValidAddress } from 'uniswap/src/utils/addresses' +import { ZodError } from 'zod' + +let _provider: JsonRpcProvider | undefined +let _chainId: string | undefined +let connectedAddresses: Address[] | undefined +const dappUrl = window.origin + +const getChainId = (): string | undefined => { + const storedChainId = dappStore.getDappInfo(dappUrl)?.lastChainId + + if (_chainId === undefined && storedChainId) { + _chainId = chainIdToHexadecimalString(storedChainId) + } + + return _chainId +} + +const getProvider = (): JsonRpcProvider | undefined => _provider +const getConnectedAddresses = (): Address[] | undefined => { + const storedDappInfo = dappStore.getDappInfo(dappUrl) + const storedConnectedAddresses = + storedDappInfo && + getOrderedConnectedAddresses(storedDappInfo.connectedAccounts, storedDappInfo.activeConnectedAddress) + return connectedAddresses ?? storedConnectedAddresses +} + +const setProvider = (newProvider: JsonRpcProvider): void => { + _provider = newProvider +} +const setChainIdAndMaybeEmit = (newChainId: string): void => { + // Only emit if the chain have changed, and it's not the first time + if (_chainId !== undefined && _chainId !== newChainId) { + emitChainChanged(newChainId) + } + _chainId = newChainId +} + +const setConnectedAddressesAndMaybeEmit = (newConnectedAddresses: Address[]): void => { + // Only emit if the addresses have changed, and it's not the first time + const normalizedNewAddresses: Address[] = newConnectedAddresses + .map((address) => getValidAddress(address)) + .filter((normalizedAddress): normalizedAddress is Address => normalizedAddress !== null) + + if (!connectedAddresses || !arraysAreEqual(connectedAddresses, normalizedNewAddresses)) { + emitAccountsChanged(normalizedNewAddresses) + } + connectedAddresses = normalizedNewAddresses +} + +const extensionEthMethodHandler = new ExtensionEthMethodHandler( + getChainId, + getProvider, + getConnectedAddresses, + setChainIdAndMaybeEmit, + setProvider, + setConnectedAddressesAndMaybeEmit, +) +const providerDirectMethodHandler = new ProviderDirectMethodHandler( + getChainId, + getProvider, + getConnectedAddresses, + setChainIdAndMaybeEmit, + setProvider, + setConnectedAddressesAndMaybeEmit, +) + +const uniswapMethodHandler = new UniswapMethodHandler( + getChainId, + getProvider, + getConnectedAddresses, + setChainIdAndMaybeEmit, + setProvider, + setConnectedAddressesAndMaybeEmit, +) + +addWindowMessageListener(isValidWindowEthereumRequest, async (request, source) => { + logger.debug('injected.ts', 'Request received for method', JSON.stringify(request), _provider) + + if (!backgroundStore.state.isOnboarded) { + rejectRequestNotOnboarded(request, source).catch((error) => + logError( + error?.message ?? 'Error rejecting request when not onboarded', + 'injected.ts', + 'WindowEthereumRequestListener', + ), + ) + return + } + + if (isProviderDirectMethod(request.method)) { + // Provider methods are handled directly by the provider instance + // (avoiding roundtrip to background service worker) + providerDirectMethodHandler.handleRequest(request, source) + return + } + + if (isUniswapMethod(request.method)) { + try { + await uniswapMethodHandler.handleRequest(request, source) + } catch (e) { + if (e instanceof ZodError) { + postParsingError(source, request.requestId, request.method) + } + const errorMessage = e instanceof Error ? e.message : 'Unknown error' + await logError(errorMessage, 'injected.ts', 'WindowEthereumRequest') + } + return + } + + if (isExtensionEthMethod(request.method)) { + try { + await extensionEthMethodHandler.handleRequest(request, source) + } catch (e) { + if (e instanceof ZodError) { + postParsingError(source, request.requestId, request.method) + } + const errorMessage = e instanceof Error ? e.message : 'Unknown error' + await logError(errorMessage, 'injected.ts', 'WindowEthereumRequest') + } + return + } + + if (isDeprecatedMethod(request.method)) { + postDeprecatedMethodError(source, request.requestId, request.method) + await logInfo('injected.ts', 'WindowEthereumRequest', 'Deprecated method', { + method: request.method, + dappUrl, + }) + return + } + + if (isUnsupportedMethod(request.method)) { + postUnknownMethodError(source, request.requestId, request.method) + await logInfo('injected.ts', 'WindowEthereumRequest', 'Unsupported method', { + method: request.method, + dappUrl, + }) + return + } + + // Handle any methods we don't know how to handle and are not in the metamask API + await logInfo('injected.ts', 'WindowEthereumRequest', 'Unrecognized method', { + method: request.method, + dappUrl, + }) + postUnknownMethodError(source, request.requestId, request.method) +}) + +externalDappMessageChannel.addMessageListener(ExtensionToDappRequestType.SwitchChain, (message) => { + setChainIdAndMaybeEmit(message.chainId) + setProvider(new JsonRpcProvider(message.providerUrl)) +}) + +externalDappMessageChannel.addMessageListener(ExtensionToDappRequestType.UpdateConnections, (message) => { + setConnectedAddressesAndMaybeEmit(message.addresses) +}) + +async function init(): Promise { + try { + await Promise.all([backgroundStore.init(), dappStore.init()]) + + const chainId = getChainId() + const provider = getProvider() + + if (chainId && !provider) { + const chainIdNum = parseInt(chainId, 16) + const defaultProvider = walletContextValue.providers.getProvider(chainIdNum, RPCType.Public) + setProvider(defaultProvider) + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + await logError(errorMessage, 'injected.ts', 'init') + } +} + +/** Helper function to reject all requests from dapps when the extension is not onboarded. */ +async function rejectRequestNotOnboarded( + request: WindowEthereumRequest, + source: MessageEventSource | null, +): Promise { + if ( + request.method === ExtensionEthMethods.eth_requestAccounts || + request.method === ExtensionEthMethods.wallet_requestPermissions + ) { + await contentScriptUtilityMessageChannel.sendMessage({ + type: ContentScriptUtilityMessageType.FocusOnboardingTab, + }) + } + + source?.postMessage({ + requestId: request.requestId, + error: serializeError(providerErrors.userRejectedRequest()), + }) +} + +init().catch(() => {}) + +async function logError( + errorMessage: string, + fileName: string, + functionName: string, + tags?: Record, +): Promise { + const message: ErrorLog = { + type: ContentScriptUtilityMessageType.ErrorLog, + message: errorMessage, + fileName, + functionName, + tags, + } + await contentScriptUtilityMessageChannel.sendMessage(message) +} + +async function logInfo( + fileName: string, + functionName: string, + message: string, + tags: Record, +): Promise { + const logMessage: InfoLog = { + type: ContentScriptUtilityMessageType.InfoLog, + fileName, + functionName, + message, + tags, + } + await contentScriptUtilityMessageChannel.sendMessage(logMessage) +} diff --git a/apps/extension/src/contentScript/methodHandlers/BaseMethodHandler.ts b/apps/extension/src/contentScript/methodHandlers/BaseMethodHandler.ts new file mode 100644 index 00000000000..e9bd18c2baa --- /dev/null +++ b/apps/extension/src/contentScript/methodHandlers/BaseMethodHandler.ts @@ -0,0 +1,16 @@ +import { JsonRpcProvider } from '@ethersproject/providers' +import { WindowEthereumRequest } from 'src/contentScript/types' + +export abstract class BaseMethodHandler { + constructor( + protected readonly getChainId: () => string | undefined, + protected readonly getProvider: () => JsonRpcProvider | undefined, + protected readonly getConnectedAddresses: () => Address[] | undefined, + protected readonly setChainIdAndMaybeEmit: (newChainId: string) => void, + protected readonly setProvider: (newProvider: JsonRpcProvider) => void, + protected readonly setConnectedAddressesAndMaybeEmit: (newConnectedAddresses: Address[]) => void, + ) {} + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + handleRequest(request: T, source: MessageEventSource | null): void {} +} diff --git a/apps/extension/src/contentScript/methodHandlers/ExtensionEthMethodHandler.ts b/apps/extension/src/contentScript/methodHandlers/ExtensionEthMethodHandler.ts new file mode 100644 index 00000000000..e62c7871e2a --- /dev/null +++ b/apps/extension/src/contentScript/methodHandlers/ExtensionEthMethodHandler.ts @@ -0,0 +1,483 @@ +import { JsonRpcProvider } from '@ethersproject/providers' +import { getPermissions } from 'src/app/features/dappRequests/permissions' +import { + DappRequestType, + DappResponseType, + SendTransactionRequest, +} from 'src/app/features/dappRequests/types/DappRequestTypes' +import { extractBaseUrl } from 'src/app/features/dappRequests/utils' +import { + contentScriptToBackgroundMessageChannel, + dappResponseMessageChannel, +} from 'src/background/messagePassing/messageChannels' +import getCalldataInfoFromTransaction from 'src/background/utils/getCalldataInfoFromTransaction' +import { + EthAccountsRequest, + EthAccountsRequestSchema, + EthChainIdRequest, + EthChainIdRequestSchema, + EthRequestAccountsRequest, + EthRequestAccountsRequestSchema, + EthSendTransactionRequest, + EthSendTransactionRequestSchema, + EthSignTypedDataV4Request, + EthSignTypedDataV4RequestSchema, + PersonalSignRequest, + PersonalSignRequestSchema, + WalletGetPermissionsRequest, + WalletGetPermissionsRequestSchema, + WalletRequestPermissionsRequest, + WalletRequestPermissionsRequestSchema, + WalletRevokePermissionsRequest, + WalletRevokePermissionsRequestSchema, + WalletSwitchEthereumChainRequest, + WalletSwitchEthereumChainRequestSchema, +} from 'src/contentScript/WindowEthereumRequestTypes' +import { BaseMethodHandler } from 'src/contentScript/methodHandlers/BaseMethodHandler' +import { ExtensionEthMethods } from 'src/contentScript/methodHandlers/requestMethods' +import { PendingResponseInfo } from 'src/contentScript/methodHandlers/types' +import { getPendingResponseInfo, postUnauthorizedError } from 'src/contentScript/methodHandlers/utils' +import { WindowEthereumRequest } from 'src/contentScript/types' +import { chainIdToHexadecimalString } from 'uniswap/src/features/chains/utils' +import { UniverseChainId } from 'uniswap/src/types/chains' +import { areAddressesEqual } from 'uniswap/src/utils/addresses' + +export class ExtensionEthMethodHandler extends BaseMethodHandler { + private readonly requestIdToSourceMap: Map = new Map() + + constructor( + getChainId: () => string | undefined, + getProvider: () => JsonRpcProvider | undefined, + getConnectedAddresses: () => Address[] | undefined, + setChainIdAndMaybeEmit: (newChainId: string) => void, + setProvider: (newProvider: JsonRpcProvider) => void, + setConnectedAddressesAndMaybeEmit: (newConnectedAddresses: Address[]) => void, + ) { + super( + getChainId, + getProvider, + getConnectedAddresses, + setChainIdAndMaybeEmit, + setProvider, + setConnectedAddressesAndMaybeEmit, + ) + + dappResponseMessageChannel.addMessageListener(DappResponseType.AccountResponse, (message) => { + const source = getPendingResponseInfo( + this.requestIdToSourceMap, + message.requestId, + DappResponseType.AccountResponse, + )?.source + + this.handleDappUpdate(message.connectedAddresses, message.chainId, message.providerUrl) + source?.postMessage({ + requestId: message.requestId, + result: message.connectedAddresses, + }) + }) + + dappResponseMessageChannel.addMessageListener(DappResponseType.ChainIdResponse, (message) => { + const source = getPendingResponseInfo( + this.requestIdToSourceMap, + message.requestId, + DappResponseType.ChainIdResponse, + )?.source + + source?.postMessage({ + requestId: message.requestId, + result: message.chainId, + }) + + const chainId = this.getChainId() + if (!chainId) { + window.postMessage({ + emitKey: 'connect', + emitValue: { + chainId: message.chainId, + }, + }) + } + + this.setChainIdAndMaybeEmit(message.chainId) + }) + + dappResponseMessageChannel.addMessageListener(DappResponseType.ChainChangeResponse, (message) => { + const source = getPendingResponseInfo( + this.requestIdToSourceMap, + message.requestId, + DappResponseType.ChainChangeResponse, + )?.source + + this.setChainIdAndMaybeEmit(message.chainId) + this.setProvider(new JsonRpcProvider(message.providerUrl)) + source?.postMessage({ + requestId: message.requestId, + result: message.chainId, + }) + }) + + dappResponseMessageChannel.addMessageListener(DappResponseType.SendTransactionResponse, (message) => { + const source = getPendingResponseInfo( + this.requestIdToSourceMap, + message.requestId, + DappResponseType.SendTransactionResponse, + )?.source + + source?.postMessage({ + requestId: message.requestId, + result: message.transactionResponse.hash, + }) + }) + + dappResponseMessageChannel.addMessageListener(DappResponseType.SignMessageResponse, (message) => { + const source = getPendingResponseInfo( + this.requestIdToSourceMap, + message.requestId, + DappResponseType.SignMessageResponse, + )?.source + + source?.postMessage({ + requestId: message.requestId, + result: message.signature, + }) + }) + + dappResponseMessageChannel.addMessageListener(DappResponseType.SignTransactionResponse, (message) => { + const source = getPendingResponseInfo( + this.requestIdToSourceMap, + message.requestId, + DappResponseType.SignTransactionResponse, + )?.source + + source?.postMessage({ + requestId: message.requestId, + result: message.signedTransactionHash, + }) + }) + + dappResponseMessageChannel.addMessageListener(DappResponseType.SignTypedDataResponse, (message) => { + const source = getPendingResponseInfo( + this.requestIdToSourceMap, + message.requestId, + DappResponseType.SignTypedDataResponse, + )?.source + + source?.postMessage({ + requestId: message.requestId, + result: message.signature, + }) + }) + + dappResponseMessageChannel.addMessageListener(DappResponseType.RequestPermissionsResponse, (message) => { + if (message.accounts) { + const { connectedAddresses, chainId, providerUrl } = message.accounts + this.handleDappUpdate(connectedAddresses, chainId, providerUrl) + } + const source = getPendingResponseInfo( + this.requestIdToSourceMap, + message.requestId, + DappResponseType.RequestPermissionsResponse, + )?.source + + source?.postMessage({ + requestId: message.requestId, + result: message.permissions, + }) + }) + + dappResponseMessageChannel.addMessageListener(DappResponseType.RevokePermissionsResponse, (message) => { + const source = getPendingResponseInfo( + this.requestIdToSourceMap, + message.requestId, + DappResponseType.RevokePermissionsResponse, + )?.source + + source?.postMessage({ + requestId: message.requestId, + result: null, + }) + }) + + dappResponseMessageChannel.addMessageListener(DappResponseType.ErrorResponse, (message) => { + const source = getPendingResponseInfo( + this.requestIdToSourceMap, + message.requestId, + DappResponseType.ErrorResponse, + )?.source + + source?.postMessage(message) + }) + } + + private isAuthorized(): boolean { + const connectedAddresses = this.getConnectedAddresses() + return !!connectedAddresses?.length + } + + private isConnectedToDapp(): boolean { + // Fields that should be populated for connected dapps + return Boolean(this.getConnectedAddresses()?.length && this.getChainId() && this.getProvider()) + } + + private handleDappUpdate(connectedAddresses: string[], chainId: string, providerUrl: string): void { + this.setConnectedAddressesAndMaybeEmit(connectedAddresses) + this.setChainIdAndMaybeEmit(chainId) + this.setProvider(new JsonRpcProvider(providerUrl)) + } + + async handleRequest(request: WindowEthereumRequest, source: MessageEventSource | null): Promise { + switch (request.method) { + case ExtensionEthMethods.eth_chainId: { + const ethChainIdRequest = EthChainIdRequestSchema.parse(request) + await this.handleEthChainIdRequest(ethChainIdRequest, source) + break + } + case ExtensionEthMethods.eth_requestAccounts: { + const parsedRequest = EthRequestAccountsRequestSchema.parse(request) + await this.handleEthRequestAccounts(parsedRequest, source) + break + } + case ExtensionEthMethods.eth_accounts: { + const parsedRequest = EthAccountsRequestSchema.parse(request) + await this.handleEthAccounts(parsedRequest, source) + break + } + case ExtensionEthMethods.eth_sendTransaction: { + if (!this.isAuthorized()) { + postUnauthorizedError(source, request.requestId) + return + } + const parsedRequest = EthSendTransactionRequestSchema.parse(request) + await this.handleEthSendTransaction(parsedRequest, source) + break + } + case ExtensionEthMethods.wallet_switchEthereumChain: { + if (!this.isAuthorized()) { + postUnauthorizedError(source, request.requestId) + return + } + const parsedRequest = WalletSwitchEthereumChainRequestSchema.parse(request) + await this.handleWalletSwitchEthereumChain(parsedRequest, source) + break + } + case ExtensionEthMethods.wallet_getPermissions: { + const parsedRequest = WalletGetPermissionsRequestSchema.parse(request) + await this.handleWalletGetPermissions(parsedRequest, source) + break + } + + case ExtensionEthMethods.wallet_requestPermissions: { + const parsedRequest = WalletRequestPermissionsRequestSchema.parse(request) + await this.handleWalletRequestPermissions(parsedRequest, source) + break + } + case ExtensionEthMethods.wallet_revokePermissions: { + const parsedRequest = WalletRevokePermissionsRequestSchema.parse(request) + await this.handleWalletRevokePermissions(parsedRequest, source) + break + } + case ExtensionEthMethods.personal_sign: { + if (!this.isAuthorized()) { + postUnauthorizedError(source, request.requestId) + return + } + + const parsedRequest = PersonalSignRequestSchema.parse(request) + if (!this.isValidRequestAddress(parsedRequest.address)) { + postUnauthorizedError(source, request.requestId) + return + } + + await this.handlePersonalSign(parsedRequest, source) + break + } + case ExtensionEthMethods.eth_signTypedData_v4: { + if (!this.isAuthorized()) { + postUnauthorizedError(source, request.requestId) + return + } + + const parsedRequest = EthSignTypedDataV4RequestSchema.parse(request) + if (!this.isValidRequestAddress(parsedRequest.address)) { + postUnauthorizedError(source, request.requestId) + return + } + + await this.handleEthSignTypedData(parsedRequest, source) + break + } + } + } + + async handleEthChainIdRequest(request: EthChainIdRequest, source: MessageEventSource | null): Promise { + // Defaults to mainnet for unconnected dapps + const chainId = this.getChainId() ?? chainIdToHexadecimalString(UniverseChainId.Mainnet) + + source?.postMessage({ + requestId: request.requestId, + result: chainId, + }) + return + } + + async handleEthRequestAccounts(request: EthRequestAccountsRequest, source: MessageEventSource | null): Promise { + const connectedAddresses = this.getConnectedAddresses() + + if (connectedAddresses?.length && this.isConnectedToDapp()) { + source?.postMessage({ + requestId: request.requestId, + result: connectedAddresses, + }) + return + } + + this.requestIdToSourceMap.set(request.requestId, { + type: DappResponseType.AccountResponse, + source, + }) + + await contentScriptToBackgroundMessageChannel.sendMessage({ + type: DappRequestType.RequestAccount, + requestId: request.requestId, + }) + } + + async handleEthAccounts(request: EthAccountsRequest, source: MessageEventSource | null): Promise { + const connectedAddresses = this.getConnectedAddresses() + + if (connectedAddresses?.length && this.isConnectedToDapp()) { + source?.postMessage({ + requestId: request.requestId, + result: connectedAddresses, + }) + return + } + + postUnauthorizedError(source, request.requestId) + } + + async handleEthSendTransaction(request: EthSendTransactionRequest, source: MessageEventSource | null): Promise { + this.requestIdToSourceMap.set(request.requestId, { + type: DappResponseType.SendTransactionResponse, + source, + }) + + const sendTransactionRequest: SendTransactionRequest = { + type: DappRequestType.SendTransaction, + requestId: request.requestId, + transaction: adaptTransactionForEthers(request.transaction), + } + + // native transactions like native send will not have populated data field + const requestIncludesData = Boolean(request.transaction.data) + + if (requestIncludesData && request.transaction.data !== '0x') { + Object.assign(sendTransactionRequest, getCalldataInfoFromTransaction(request.transaction)) + } + + await contentScriptToBackgroundMessageChannel.sendMessage(sendTransactionRequest) + } + + async handlePersonalSign(request: PersonalSignRequest, source: MessageEventSource | null): Promise { + this.requestIdToSourceMap.set(request.requestId, { + type: DappResponseType.SignMessageResponse, + source, + }) + + await contentScriptToBackgroundMessageChannel.sendMessage({ + type: DappRequestType.SignMessage, + requestId: request.requestId, + messageHex: request.messageHex, + address: request.address, + }) + } + + async handleEthSignTypedData(request: EthSignTypedDataV4Request, source: MessageEventSource | null): Promise { + this.requestIdToSourceMap.set(request.requestId, { + type: DappResponseType.SignTypedDataResponse, + source, + }) + + await contentScriptToBackgroundMessageChannel.sendMessage({ + type: DappRequestType.SignTypedData, + requestId: request.requestId, + typedData: request.typedData, + address: request.address, + }) + } + + async handleWalletSwitchEthereumChain( + request: WalletSwitchEthereumChainRequest, + source: MessageEventSource | null, + ): Promise { + this.requestIdToSourceMap.set(request.requestId, { + type: DappResponseType.ChainChangeResponse, + source, + }) + + await contentScriptToBackgroundMessageChannel.sendMessage({ + type: DappRequestType.ChangeChain, + requestId: request.requestId, + chainId: request.chainId, + }) + } + + async handleWalletGetPermissions( + request: WalletGetPermissionsRequest, + source: MessageEventSource | null, + ): Promise { + const dappUrl = extractBaseUrl(window.origin) + const connectedAddresses = this.getConnectedAddresses() + + const permissions = getPermissions(dappUrl, connectedAddresses) + + source?.postMessage({ + requestId: request.requestId, + result: permissions, + }) + } + + async handleWalletRequestPermissions( + request: WalletRequestPermissionsRequest, + source: MessageEventSource | null, + ): Promise { + this.requestIdToSourceMap.set(request.requestId, { + type: DappResponseType.RequestPermissionsResponse, + source, + }) + + await contentScriptToBackgroundMessageChannel.sendMessage({ + type: DappRequestType.RequestPermissions, + requestId: request.requestId, + permissions: request.permissions, + }) + } + + async handleWalletRevokePermissions( + request: WalletRevokePermissionsRequest, + source: MessageEventSource | null, + ): Promise { + this.requestIdToSourceMap.set(request.requestId, { + type: DappResponseType.RevokePermissionsResponse, + source, + }) + + await contentScriptToBackgroundMessageChannel.sendMessage({ + type: DappRequestType.RevokePermissions, + requestId: request.requestId, + permissions: request.permissions, + }) + } + + private isValidRequestAddress(address: string): boolean { + return (this.getConnectedAddresses() ?? []).some((connectedAddress) => areAddressesEqual(connectedAddress, address)) + } +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function adaptTransactionForEthers(transaction: any): any { + if (typeof transaction.chainId === 'string') { + transaction.chainId = parseInt(transaction.chainId, 16) + } + return transaction +} diff --git a/apps/extension/src/contentScript/methodHandlers/ProviderDirectMethodHandler.ts b/apps/extension/src/contentScript/methodHandlers/ProviderDirectMethodHandler.ts new file mode 100644 index 00000000000..676c9446a8f --- /dev/null +++ b/apps/extension/src/contentScript/methodHandlers/ProviderDirectMethodHandler.ts @@ -0,0 +1,115 @@ +import { JsonRpcProvider } from '@ethersproject/providers' +import { BigNumber } from 'ethers' +import { BaseMethodHandler } from 'src/contentScript/methodHandlers/BaseMethodHandler' +import { ProviderDirectMethods } from 'src/contentScript/methodHandlers/requestMethods' +import { WindowEthereumRequest } from 'src/contentScript/types' +import { logger } from 'utilities/src/logger/logger' + +/** + * Handles all provider direct requests + * Maps Ethereum JSON-RPC methods to their corresponding ethers.js provider method calls. + */ + +export class ProviderDirectMethodHandler extends BaseMethodHandler { + private methodHandlers: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: string]: (provider: JsonRpcProvider, params: any[]) => Promise + } + + constructor( + getChainId: () => string | undefined, + getProvider: () => JsonRpcProvider | undefined, + getConnectedAddresses: () => Address[] | undefined, + setChainIdAndMaybeEmit: (newChainId: string) => void, + setProvider: (newProvider: JsonRpcProvider) => void, + setConnectedAddressesAndMaybeEmit: (newConnectedAddresses: Address[]) => void, + ) { + super( + getChainId, + getProvider, + getConnectedAddresses, + setChainIdAndMaybeEmit, + setProvider, + setConnectedAddressesAndMaybeEmit, + ) + + this.methodHandlers = { + /* eslint-disable @typescript-eslint/explicit-function-return-type */ + [ProviderDirectMethods.eth_getBalance]: (provider, params) => provider.getBalance(params[0]), + [ProviderDirectMethods.eth_getCode]: (provider, params) => provider.getCode(params[0]), + [ProviderDirectMethods.eth_getStorageAt]: (provider, params) => provider.getStorageAt(params[0], params[1]), + [ProviderDirectMethods.eth_getTransactionCount]: (provider, params) => provider.getTransactionCount(params[0]), + [ProviderDirectMethods.eth_blockNumber]: (provider, _params) => provider.getBlockNumber(), + [ProviderDirectMethods.eth_getBlockByNumber]: (provider, params) => provider.getBlock(params[0]), + [ProviderDirectMethods.eth_call]: (provider, params) => provider.call(params[0]), + [ProviderDirectMethods.eth_gasPrice]: (provider, _params) => provider.getGasPrice(), + [ProviderDirectMethods.eth_estimateGas]: (provider, params) => provider.estimateGas(params[0]), + [ProviderDirectMethods.eth_getTransactionByHash]: (provider, params) => provider.getTransaction(params[0]), + [ProviderDirectMethods.eth_getTransactionReceipt]: (provider, params) => + provider.getTransactionReceipt(params[0]), + [ProviderDirectMethods.net_version]: async (provider, params) => provider.send('net_version', params), + } + } + + handleRequest(request: WindowEthereumRequest, source: MessageEventSource | null): void { + const handler = this.methodHandlers[request.method] + if (handler) { + const provider = this.getProvider() + if (!provider) { + // TODO: Handle error for disconnection + return + } + const response = handler(provider, request.params) + this.handleResponse(response, source, request.requestId) + } else { + // We shouldn't end up here because injected.ts checks that the method is supported before calling this function + logger.error(new Error('Unexpected method requested'), { + tags: { + file: 'ProviderDirectMethodHandler.ts', + function: 'handleRequest', + }, + extra: { + method: request.method, + dapp: window.origin, + }, + }) + } + } + + private handleResponse( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + response: Promise, + source: MessageEventSource | null, + requestId: string, + ): void { + response + .then((result) => { + source?.postMessage({ + requestId, + result: JSON.parse( + JSON.stringify(result, (_key, value) => { + if (!value) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return value + } else if (BigNumber.isBigNumber(value)) { + return value.toHexString() + } else if (value.type === 'BigNumber' && value.hex) { + // Unsure of why but sometimes the provider has converted the BigNumber with BigNumber.toJSON() e.g. eth_getBlockByNumber + // which is a format not currently accepted by some dapps e.g. Morpho + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return value.hex + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return value + }), + ), + }) + }) + .catch((error) => { + source?.postMessage({ + requestId, + error, + }) + }) + } +} diff --git a/apps/extension/src/contentScript/methodHandlers/UniswapMethodHandler.ts b/apps/extension/src/contentScript/methodHandlers/UniswapMethodHandler.ts new file mode 100644 index 00000000000..bbb888cc949 --- /dev/null +++ b/apps/extension/src/contentScript/methodHandlers/UniswapMethodHandler.ts @@ -0,0 +1,81 @@ +import { JsonRpcProvider } from '@ethersproject/providers' +import { DappRequestType, DappResponseType } from 'src/app/features/dappRequests/types/DappRequestTypes' +import { + contentScriptToBackgroundMessageChannel, + dappResponseMessageChannel, +} from 'src/background/messagePassing/messageChannels' +import { + UniswapOpenSidebarRequest, + UniswapOpenSidebarRequestSchema, +} from 'src/contentScript/WindowEthereumRequestTypes' +import { BaseMethodHandler } from 'src/contentScript/methodHandlers/BaseMethodHandler' +import { UniswapMethods } from 'src/contentScript/methodHandlers/requestMethods' +import { PendingResponseInfo } from 'src/contentScript/methodHandlers/types' +import { getPendingResponseInfo } from 'src/contentScript/methodHandlers/utils' +import { WindowEthereumRequest } from 'src/contentScript/types' +import { logger } from 'utilities/src/logger/logger' + +/** + * Handles all uniswap-specific requests + */ + +export class UniswapMethodHandler extends BaseMethodHandler { + private readonly requestIdToSourceMap: Map = new Map() + + constructor( + getChainId: () => string | undefined, + getProvider: () => JsonRpcProvider | undefined, + getConnectedAddresses: () => Address[] | undefined, + setChainIdAndMaybeEmit: (newChainId: string) => void, + setProvider: (newProvider: JsonRpcProvider) => void, + setConnectedAddressesAndMaybeEmit: (newConnectedAddresses: Address[]) => void, + ) { + super( + getChainId, + getProvider, + getConnectedAddresses, + setChainIdAndMaybeEmit, + setProvider, + setConnectedAddressesAndMaybeEmit, + ) + + dappResponseMessageChannel.addMessageListener(DappResponseType.UniswapOpenSidebarResponse, (message) => { + const source = getPendingResponseInfo( + this.requestIdToSourceMap, + message.requestId, + DappResponseType.UniswapOpenSidebarResponse, + )?.source + + source?.postMessage({ + requestId: message.requestId, + }) + }) + } + + async handleRequest(request: WindowEthereumRequest, source: MessageEventSource | null): Promise { + switch (request.method) { + case UniswapMethods.uniswap_openSidebar: { + logger.debug("Handling 'uniswap_openSidebar' request", request.method, request.toString()) + const uniswapOpenTokensRequest = UniswapOpenSidebarRequestSchema.parse(request) + await this.handleUniswapOpenSidebarRequest(uniswapOpenTokensRequest, source) + break + } + } + } + + private async handleUniswapOpenSidebarRequest( + request: UniswapOpenSidebarRequest, + source: MessageEventSource | null, + ): Promise { + this.requestIdToSourceMap.set(request.requestId, { + source, + type: DappResponseType.UniswapOpenSidebarResponse, + }) + + await contentScriptToBackgroundMessageChannel.sendMessage({ + type: DappRequestType.UniswapOpenSidebar, + requestId: request.requestId, + tab: request.tab, + }) + } +} diff --git a/apps/extension/src/contentScript/methodHandlers/emitUtils.ts b/apps/extension/src/contentScript/methodHandlers/emitUtils.ts new file mode 100644 index 00000000000..fb2f96b7770 --- /dev/null +++ b/apps/extension/src/contentScript/methodHandlers/emitUtils.ts @@ -0,0 +1,12 @@ +export function emitChainChanged(newChainId: string): void { + window?.postMessage({ + emitKey: 'chainChanged', + emitValue: newChainId, + }) +} +export function emitAccountsChanged(newConnectedAddresses: Address[]): void { + window?.postMessage({ + emitKey: 'accountsChanged', + emitValue: newConnectedAddresses, + }) +} diff --git a/apps/extension/src/contentScript/methodHandlers/requestMethods.ts b/apps/extension/src/contentScript/methodHandlers/requestMethods.ts new file mode 100644 index 00000000000..c147e5f8bf4 --- /dev/null +++ b/apps/extension/src/contentScript/methodHandlers/requestMethods.ts @@ -0,0 +1,89 @@ +// List of eth methods that the extension will handle +/* eslint-disable @typescript-eslint/naming-convention */ +export enum ExtensionEthMethods { + eth_chainId = 'eth_chainId', + eth_requestAccounts = 'eth_requestAccounts', + eth_accounts = 'eth_accounts', + eth_sendTransaction = 'eth_sendTransaction', + personal_sign = 'personal_sign', + wallet_switchEthereumChain = 'wallet_switchEthereumChain', + wallet_getPermissions = 'wallet_getPermissions', + wallet_requestPermissions = 'wallet_requestPermissions', + wallet_revokePermissions = 'wallet_revokePermissions', + eth_signTypedData_v4 = 'eth_signTypedData_v4', +} + +// Custom Uniswap methods that the extension will handle +/* eslint-disable @typescript-eslint/naming-convention */ +export enum UniswapMethods { + uniswap_openSidebar = 'uniswap_openSidebar', +} + +// Methods that are not supported by the extension because they are deprecated +/* eslint-disable @typescript-eslint/naming-convention */ +export enum DeprecatedEthMethods { + eth_sign = 'eth_sign', // Security risk + eth_signTypedData_v3 = 'eth_signTypedData_v3', + eth_signTypedData_v1 = 'eth_signTypedData_v1', + eth_decrypt = 'eth_decrypt', + eth_getEncryptionPublicKey = 'eth_getEncryptionPublicKey', +} + +// Methods that are handled by Metamask but not by the extension. These are logged +// so we can either display an error to the user or track frequency. +// Depending on the frequency with which we see these methods we could show an error +// in the sidebar for users. +// The methods come from: https://docs.metamask.io/wallet/reference/json-rpc-api/ +/* eslint-disable @typescript-eslint/naming-convention */ +export enum UnsupportedEthMethods { + wallet_addEthereumChain = 'wallet_addEthereumChain', + wallet_registerOnboarding = 'wallet_registerOnboarding', + wallet_watchAsset = 'wallet_watchAsset', + wallet_scanQRCode = 'wallet_scanQRCode', + wallet_getSnaps = 'wallet_getSnaps', + wallet_requestSnaps = 'wallet_requestSnaps', + wallet_snap = 'wallet_snap', + wallet_invokeSnap = 'wallet_invokeSnap', + web3_clientVersion = 'web3_clientVersion', + eth_subscribe = 'eth_subscribe', + eth_unsubscribe = 'eth_unsubscribe', + eth_blobBaseFee = 'eth_blobBaseFee', + eth_coinbase = 'eth_coinbase', + eth_feeHistory = 'eth_feeHistory', + eth_getBlockByHash = 'eth_getBlockByHash', + eth_getBlockTransactionCountByHash = 'eth_getBlockTransactionCountByHash', + eth_getBlockTransactionCountByNumber = 'eth_getBlockTransactionCountByNumber', + eth_getFilterChanges = 'eth_getFilterChanges', + eth_getFilterLogs = 'eth_getFilterLogs', + eth_getLogs = 'eth_getLogs', + eth_getProof = 'eth_getProof', + eth_getStorageAt = 'eth_getStorageAt', + eth_getTransactionByBlockHashAndIndex = 'eth_getTransactionByBlockHashAndIndex', + eth_getTransactionByBlockNumberAndIndex = 'eth_getTransactionByBlockNumberAndIndex', + eth_getTransactionCount = 'eth_getTransactionCount', + eth_getUncleCountByBlockHash = 'eth_getUncleCountByBlockHash', + eth_getUncleCountByBlockNumber = 'eth_getUncleCountByBlockNumber', + eth_maxPriorityFeePerGas = 'eth_maxPriorityFeePerGas', + eth_newBlockFilter = 'eth_newBlockFilter', + eth_newFilter = 'eth_newFilter', + eth_newPendingTransactionFilter = 'eth_newPendingTransactionFilter', + eth_sendRawTransaction = 'eth_sendRawTransaction', + eth_syncing = 'eth_syncing', + eth_uninstallFilter = 'eth_uninstallFilter', + eth_signTransaction = 'eth_signTransaction', +} + +export enum ProviderDirectMethods { + eth_getBalance = 'eth_getBalance', + eth_getCode = 'eth_getCode', + eth_getStorageAt = 'eth_getStorageAt', + eth_getTransactionCount = 'eth_getTransactionCount', + eth_blockNumber = 'eth_blockNumber', + eth_getBlockByNumber = 'eth_getBlockByNumber', + eth_call = 'eth_call', + eth_gasPrice = 'eth_gasPrice', + eth_estimateGas = 'eth_estimateGas', + eth_getTransactionByHash = 'eth_getTransactionByHash', + eth_getTransactionReceipt = 'eth_getTransactionReceipt', + net_version = 'net_version', +} diff --git a/apps/extension/src/contentScript/methodHandlers/types.ts b/apps/extension/src/contentScript/methodHandlers/types.ts new file mode 100644 index 00000000000..c0f77c234e1 --- /dev/null +++ b/apps/extension/src/contentScript/methodHandlers/types.ts @@ -0,0 +1,6 @@ +import { DappResponseType } from 'src/app/features/dappRequests/types/DappRequestTypes' + +export type PendingResponseInfo = { + type: DappResponseType + source: MessageEventSource | null +} diff --git a/apps/extension/src/contentScript/methodHandlers/utils.ts b/apps/extension/src/contentScript/methodHandlers/utils.ts new file mode 100644 index 00000000000..ecbf2c59690 --- /dev/null +++ b/apps/extension/src/contentScript/methodHandlers/utils.ts @@ -0,0 +1,89 @@ +import { providerErrors, serializeError } from '@metamask/rpc-errors' +import { DappResponseType } from 'src/app/features/dappRequests/types/DappRequestTypes' +import { + DeprecatedEthMethods, + ExtensionEthMethods, + ProviderDirectMethods, + UniswapMethods, + UnsupportedEthMethods, +} from 'src/contentScript/methodHandlers/requestMethods' +import { PendingResponseInfo } from 'src/contentScript/methodHandlers/types' +import { logger } from 'utilities/src/logger/logger' + +export function isProviderDirectMethod(method: string): boolean { + return Object.keys(ProviderDirectMethods).includes(method) +} + +export function isUniswapMethod(method: string): boolean { + return Object.keys(UniswapMethods).includes(method) +} + +export function isExtensionEthMethod(method: string): boolean { + return Object.keys(ExtensionEthMethods).includes(method) +} + +export function isDeprecatedMethod(method: string): boolean { + return Object.keys(DeprecatedEthMethods).includes(method) +} + +export function isUnsupportedMethod(method: string): boolean { + return Object.keys(UnsupportedEthMethods).includes(method) +} + +export function postDeprecatedMethodError(source: MessageEventSource | null, requestId: string, method: string): void { + source?.postMessage({ + requestId, + error: serializeError( + providerErrors.unsupportedMethod(`Uniswap Wallet does not support ${method} as it is deprecated`), + ), + }) +} + +export function postUnknownMethodError(source: MessageEventSource | null, requestId: string, method: string): void { + source?.postMessage({ + requestId, + error: serializeError(providerErrors.unsupportedMethod(`Uniswap Wallet does not support ${method}`)), + }) +} + +export function postUnauthorizedError(source: MessageEventSource | null, requestId: string): void { + source?.postMessage({ + requestId, + error: serializeError(providerErrors.unauthorized()), + }) +} + +export function postParsingError(source: MessageEventSource | null, requestId: string, method: string): void { + source?.postMessage({ + requestId, + error: serializeError( + providerErrors.unsupportedMethod(`Uniswap Wallet could not parse the ${method} request properly`), + ), + }) +} + +export function getPendingResponseInfo( + requestIdToSourceMap: Map, + requestId: string, + type: DappResponseType, +): PendingResponseInfo | undefined { + const pendingResponseInfo = requestIdToSourceMap.get(requestId) + if (pendingResponseInfo) { + requestIdToSourceMap.delete(requestId) + + if (type !== DappResponseType.ErrorResponse && type !== pendingResponseInfo.type) { + logger.error( + `Response type doesn't match expected type, expected: ${pendingResponseInfo.type}, actual: ${type}`, + { + tags: { + file: 'injected.ts', + function: 'validateResponse', + }, + }, + ) + } + return pendingResponseInfo + } + + return undefined +} diff --git a/apps/extension/src/contentScript/types.ts b/apps/extension/src/contentScript/types.ts new file mode 100644 index 00000000000..d55bff9b096 --- /dev/null +++ b/apps/extension/src/contentScript/types.ts @@ -0,0 +1,37 @@ +import { z } from 'zod' + +/* eslint-disable no-restricted-syntax */ +const ExtensionResponseSchema = z + .object({ + requestId: z.string(), + result: z.any().optional(), + error: z.any().optional(), + }) + .refine((data) => data.result !== undefined || data.error !== undefined, { + message: 'Either result or error must be defined', + }) + +export type ExtensionResponse = z.infer + +export const isValidExtensionResponse = (response: unknown): response is ExtensionResponse => + ExtensionResponseSchema.safeParse(response).success + +export const WindowEthereumRequestSchema = z.object({ + method: z.string(), + params: z.any(), + requestId: z.string(), +}) +export type WindowEthereumRequest = z.infer + +export const isValidWindowEthereumRequest = (request: unknown): request is WindowEthereumRequest => + WindowEthereumRequestSchema.safeParse(request).success + +export const ContentScriptToProxyEmissionSchema = z.object({ + emitKey: z.string(), + emitValue: z.any(), +}) + +export type ContentScriptToProxyEmission = z.infer + +export const isValidContentScriptToProxyEmission = (request: unknown): request is ContentScriptToProxyEmission => + ContentScriptToProxyEmissionSchema.safeParse(request).success diff --git a/apps/extension/src/declarations.d.ts b/apps/extension/src/declarations.d.ts new file mode 100644 index 00000000000..d2ecf6ad63d --- /dev/null +++ b/apps/extension/src/declarations.d.ts @@ -0,0 +1,6 @@ +declare module '*.svg' { + import React from 'react' + import { SvgProps } from 'react-native-svg' + const content: React.FC + export default content +} diff --git a/apps/extension/src/env.d.ts b/apps/extension/src/env.d.ts new file mode 100644 index 00000000000..fd6eabefe81 --- /dev/null +++ b/apps/extension/src/env.d.ts @@ -0,0 +1,8 @@ +import { config } from 'ui/src/tamagui.config' + +type Conf = typeof config + +declare module 'tamagui' { + // eslint-disable-next-line @typescript-eslint/no-empty-interface + interface TamaguiCustomConfig extends Conf {} +} diff --git a/apps/extension/src/logo.svg b/apps/extension/src/logo.svg new file mode 100644 index 00000000000..6b60c1042f5 --- /dev/null +++ b/apps/extension/src/logo.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/apps/extension/src/manifest.json b/apps/extension/src/manifest.json new file mode 100644 index 00000000000..683549ddd5a --- /dev/null +++ b/apps/extension/src/manifest.json @@ -0,0 +1,75 @@ +{ + "manifest_version": 3, + "name": "Uniswap Extension", + "description": "The Uniswap Extension is a self-custody crypto wallet that's built for swapping.", + "version": "1.0.3", + "minimum_chrome_version": "116", + "icons": { + "16": "assets/icon16.png", + "32": "assets/icon32.png", + "48": "assets/icon48.png", + "128": "assets/icon128.png" + }, + "action": { + "default_icon": { + "16": "assets/icon16.png", + "32": "assets/icon32.png", + "48": "assets/icon48.png", + "128": "assets/icon128.png" + } + }, + "side_panel": { + "default_path": "sidebar.html" + }, + "background": { + "service_worker": "background.js", + "type": "module" + }, + "permissions": [ + "alarms", + "notifications", + "sidePanel", + "storage", + "tabs" + ], + "content_scripts": [ + { + "id": "injected", + "run_at": "document_start", + "matches": [ + "http://127.0.0.1/*", + "http://localhost/*", + "https://*/*" + ], + "js": [ + "injected.js" + ] + }, + { + "id": "ethereum", + "run_at": "document_start", + "matches": [ + "http://127.0.0.1/*", + "http://localhost/*", + "https://*/*" + ], + "js": [ + "ethereum.js" + ], + "world": "MAIN" + } + ], + "externally_connectable": { + "ids": [], + "matches": [] + }, + "commands": { + "_execute_action": { + "suggested_key": { + "default": "Ctrl+Shift+U", + "mac": "Command+Shift+U" + }, + "description": "Toggles the sidebar" + } + } +} diff --git a/apps/extension/src/onboarding.html b/apps/extension/src/onboarding.html new file mode 100644 index 00000000000..67622884299 --- /dev/null +++ b/apps/extension/src/onboarding.html @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + Uniswap Extension + + + +
+ + + + diff --git a/apps/extension/src/onboarding/onboarding.tsx b/apps/extension/src/onboarding/onboarding.tsx new file mode 100644 index 00000000000..a583e37a96b --- /dev/null +++ b/apps/extension/src/onboarding/onboarding.tsx @@ -0,0 +1,55 @@ +// eslint-disable-next-line @typescript-eslint/triple-slash-reference +/// + +import { createRoot } from 'react-dom/client' +import { OptionalStrictMode } from 'src/app/components/OptionalStrictMode' +import OnboardingApp from 'src/app/OnboardingApp' +import { initializeSentry, SentryAppNameTag } from 'src/app/sentry' +import { getLocalUserId } from 'src/app/utils/storage' +import { initializeReduxStore } from 'src/store/store' +import { ExtensionAppLocation, StoreSynchronization } from 'src/store/storeSynchronization' +import { logger } from 'utilities/src/logger/logger' +;(globalThis as any).regeneratorRuntime = undefined // eslint-disable-line @typescript-eslint/no-explicit-any +// The globalThis.regeneratorRuntime = undefined addresses a potentially unsafe-eval problem +// see https://github.com/facebook/regenerator/issues/378#issuecomment-802628326 + +getLocalUserId() + .then((userId) => { + initializeSentry(SentryAppNameTag.Onboarding, userId) + }) + .catch((error) => { + logger.error(error, { + tags: { file: 'SidebarApp.tsx', function: 'getLocalUserId' }, + }) + }) +async function initOnboarding(): Promise { + await initializeReduxStore() + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const container = document.getElementById('onboarding-root')! + const root = createRoot(container) + + root.render( + + + , + ) +} + +StoreSynchronization.init(ExtensionAppLocation.Tab).catch((error) => { + logger.error(error, { + tags: { + file: 'onboarding.ts', + function: 'initPrimaryInstanceHandler', + }, + }) +}) + +initOnboarding().catch((error) => { + logger.error(error, { + tags: { + file: 'onboarding.ts', + function: 'initOnboarding', + }, + }) +}) diff --git a/apps/extension/src/sidebar.html b/apps/extension/src/sidebar.html new file mode 100644 index 00000000000..d918c44aa92 --- /dev/null +++ b/apps/extension/src/sidebar.html @@ -0,0 +1,98 @@ + + + + + + + + + + + + + Uniswap Extension + + +
+ + + diff --git a/apps/extension/src/sidebar/loadSidebar.ts b/apps/extension/src/sidebar/loadSidebar.ts new file mode 100644 index 00000000000..747be9aa8a9 --- /dev/null +++ b/apps/extension/src/sidebar/loadSidebar.ts @@ -0,0 +1,18 @@ +/** + * IMPORTANT: we should keep this file very light. Do not import anything here. + * + * The browser was taking too long to interpret the react JS bundle and initialize the react app, + * so we're now splitting this up and slightly delaying the react bundle execution. + * By doing this, the first render happens faster and there's no longer a flash of a different color background (the default "no background" color). + * Instead, the HTML is now rendered immediately, with the right background color from the inline style. + * + * For video comparison of the before and after, check out https://github.com/Uniswap/universe/pull/9294 + */ + +setTimeout(() => { + const script = document.createElement('script') + script.type = 'text/javascript' + script.async = true + script.src = './sidebar.js' + document.body.appendChild(script) +}, 10) diff --git a/apps/extension/src/sidebar/sidebar.tsx b/apps/extension/src/sidebar/sidebar.tsx new file mode 100644 index 00000000000..4d254f4c36e --- /dev/null +++ b/apps/extension/src/sidebar/sidebar.tsx @@ -0,0 +1,55 @@ +// eslint-disable-next-line @typescript-eslint/triple-slash-reference +/// + +import 'src/app/utils/devtools' +import 'symbol-observable' // Needed by `reduxed-chrome-storage` as polyfill, order matters + +import { createRoot } from 'react-dom/client' +import SidebarApp from 'src/app/SidebarApp' +import { OptionalStrictMode } from 'src/app/components/OptionalStrictMode' +import { onboardingMessageChannel } from 'src/background/messagePassing/messageChannels' +import { OnboardingMessageType } from 'src/background/messagePassing/types/ExtensionMessages' +import { initializeReduxStore } from 'src/store/store' +import { ExtensionAppLocation, StoreSynchronization } from 'src/store/storeSynchronization' +import { initializeScrollWatcher } from 'uniswap/src/components/modals/ScrollLock' +import { logger } from 'utilities/src/logger/logger' +;(globalThis as any).regeneratorRuntime = undefined // eslint-disable-line @typescript-eslint/no-explicit-any +// The globalThis.regeneratorRuntime = undefined addresses a potentially unsafe-eval problem +// see https://github.com/facebook/regenerator/issues/378#issuecomment-802628326 + +async function initSidebar(): Promise { + await initializeReduxStore() + await onboardingMessageChannel.sendMessage({ + type: OnboardingMessageType.SidebarOpened, + }) + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const container = window.document.querySelector('#root')! + const root = createRoot(container) + + root.render( + + + , + ) +} + +StoreSynchronization.init(ExtensionAppLocation.SidePanel).catch((error) => { + logger.error(error, { + tags: { + file: 'sidebar.ts', + function: 'initPrimaryInstanceHandler', + }, + }) +}) + +initSidebar().catch((error) => { + logger.error(error, { + tags: { + file: 'sidebar.ts', + function: 'initSidebar', + }, + }) +}) + +initializeScrollWatcher() diff --git a/apps/extension/src/store/PrimaryAppInstanceDebugger.tsx b/apps/extension/src/store/PrimaryAppInstanceDebugger.tsx new file mode 100644 index 00000000000..42bd081e5b5 --- /dev/null +++ b/apps/extension/src/store/PrimaryAppInstanceDebugger.tsx @@ -0,0 +1,24 @@ +import { useIsPrimaryAppInstance } from 'src/store/storeSynchronization' + +// This is a dev-only component that renders a small green/red dot in the bottom right corner of the screen +// to indicate whether the current app instance is the primary one. +export default function PrimaryAppInstanceDebugger(): JSX.Element | null { + const isPrimaryAppInstance = useIsPrimaryAppInstance() + + return ( +
+ ) +} diff --git a/apps/extension/src/store/PrimaryAppInstanceDebuggerLazy.tsx b/apps/extension/src/store/PrimaryAppInstanceDebuggerLazy.tsx new file mode 100644 index 00000000000..42ff1f81cc5 --- /dev/null +++ b/apps/extension/src/store/PrimaryAppInstanceDebuggerLazy.tsx @@ -0,0 +1,7 @@ +import { lazy } from 'react' + +const PrimaryAppInstanceDebugger = lazy(() => import('src/store/PrimaryAppInstanceDebugger')) + +export function PrimaryAppInstanceDebuggerLazy(): JSX.Element | null { + return __DEV__ ? : null +} diff --git a/apps/extension/src/store/constants.ts b/apps/extension/src/store/constants.ts new file mode 100644 index 00000000000..68d633df8a9 --- /dev/null +++ b/apps/extension/src/store/constants.ts @@ -0,0 +1,2 @@ +export const PERSIST_KEY = 'root' +export const STATE_STORAGE_KEY = `persist:${PERSIST_KEY}` diff --git a/apps/extension/src/store/enhancePersistReducer.ts b/apps/extension/src/store/enhancePersistReducer.ts new file mode 100644 index 00000000000..acb1910c4cc --- /dev/null +++ b/apps/extension/src/store/enhancePersistReducer.ts @@ -0,0 +1,46 @@ +import { Action, Reducer } from 'redux' +import { logger } from 'utilities/src/logger/logger' + +// We use `any` in a few places in this file because those values truly can be anything, so that's the proper type. + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type PersistPartial = { _persist: undefined } | any + +export function enhancePersistReducer( + reducer: Reducer, +): Reducer { + return forceRehydrationFromDiskWhenResumingPersistence(reducer) +} + +/** + * Whenever the `persist/PERSIST` action is dispatched, we reset the `_persist` state in order to trigger rehydration from disk + * regardless of whether it had already rehydrated during startup. + * + * Whenever another app becomes the primary instance, `storeSynchronization.ts` calls `persistor.pause()`, + * and then when this app becomes primary again we need to not only re-start persistance but also rehydrate from disk. + * We do this by calling `persistor.persist()`, which by default will just continue persisting and skip rehydration. + * This custom enhancer ensures that the `_persist` state is reset whenever the `persist/PERSIST` action is dispatched, + * so that the internal `redux-persist` logic will rehydrate from disk again. + * + * See relevat `redux-persist` code here: https://github.com/rt2zz/redux-persist/blob/9c0baee/src/persistReducer.ts#L110 + */ +function forceRehydrationFromDiskWhenResumingPersistence( + reducer: Reducer, +): Reducer { + return (state, action) => { + if (action.type !== 'persist/PERSIST') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return reducer(state, action) + } + + logger.debug('store-synchronization', 'enhancePersistReducer', 'Resetting redux _persist state') + + const newState = { + ...state, + _persist: undefined, + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return reducer(newState, action) + } +} diff --git a/apps/extension/src/store/migrations.test.ts b/apps/extension/src/store/migrations.test.ts new file mode 100644 index 00000000000..a03b9139f51 --- /dev/null +++ b/apps/extension/src/store/migrations.test.ts @@ -0,0 +1,182 @@ +import { BigNumber } from 'ethers' +import { toIncludeSameMembers } from 'jest-extended' +import { EXTENSION_STATE_VERSION, migrations } from 'src/store/migrations' +import { getSchema, initialSchema, v0Schema, v1Schema, v2Schema, v3Schema } from 'src/store/schema' +import { UniverseChainId } from 'uniswap/src/types/chains' +import { initialBehaviorHistoryState } from 'wallet/src/features/behaviorHistory/slice' +import { initialFavoritesState } from 'wallet/src/features/favorites/slice' +import { initialFiatCurrencyState } from 'wallet/src/features/fiatCurrency/slice' +import { initialLanguageState } from 'wallet/src/features/language/slice' +import { initialNotificationsState } from 'wallet/src/features/notifications/slice' +import { initialSearchHistoryState } from 'wallet/src/features/search/searchHistorySlice' +import { initialTokensState } from 'wallet/src/features/tokens/tokensSlice' +import { initialTransactionsState } from 'wallet/src/features/transactions/slice' +import { TransactionStatus, TransactionType } from 'wallet/src/features/transactions/types' +import { initialWalletState } from 'wallet/src/features/wallet/slice' +import { createMigrate } from 'wallet/src/state/createMigrate' +import { testActivatePendingAccounts } from 'wallet/src/state/sharedMigrationsTests' +import { getAllKeysOfNestedObject } from 'wallet/src/state/testUtils' + +expect.extend({ toIncludeSameMembers }) + +describe('Redux state migrations', () => { + it('is able to perform all migrations starting from the initial schema', async () => { + const initialSchemaStub = { + ...initialSchema, + _persist: { version: -1, rehydrated: false }, + } + + const migrate = createMigrate(migrations) + const migratedSchema = await migrate(initialSchemaStub, EXTENSION_STATE_VERSION) + expect(typeof migratedSchema).toBe('object') + }) + + // If this test fails then it's likely a required property was added to the Redux state but a migration was not defined + it('migrates all the properties correctly', async () => { + const initialSchemaStub = { + ...initialSchema, + _persist: { version: -1, rehydrated: false }, + } + + const migrate = createMigrate(migrations) + const migratedSchema = await migrate(initialSchemaStub, EXTENSION_STATE_VERSION) + + // Add new slices here! + const initialState = { + appearanceSettings: { selectedAppearanceSettings: 'system' }, + blocks: { byChainId: {} }, + chains: { + byChainId: { + '1': { isActive: true }, + '10': { isActive: true }, + '137': { isActive: true }, + '42161': { isActive: true }, + }, + }, + dapp: {}, + ens: { ensForAddress: {} }, + favorites: initialFavoritesState, + fiatCurrencySettings: initialFiatCurrencyState, + languageSettings: initialLanguageState, + notifications: initialNotificationsState, + behaviorHistory: initialBehaviorHistoryState, + providers: { isInitialized: false }, + saga: {}, + searchHistory: initialSearchHistoryState, + tokenLists: {}, + tokens: initialTokensState, + transactions: initialTransactionsState, + wallet: initialWalletState, + _persist: { + version: EXTENSION_STATE_VERSION, + rehydrated: true, + }, + } + + const migratedSchemaKeys = new Set( + getAllKeysOfNestedObject(migratedSchema as Record) + ) + const latestSchemaKeys = new Set(getAllKeysOfNestedObject(getSchema())) + const initialStateKeys = new Set(getAllKeysOfNestedObject(initialState)) + + for (const key of initialStateKeys) { + if (latestSchemaKeys.has(key)) { + latestSchemaKeys.delete(key) + } + if (migratedSchemaKeys.has(key)) { + migratedSchemaKeys.delete(key) + } + initialStateKeys.delete(key) + } + + expect(migratedSchemaKeys.size).toBe(0) + expect(latestSchemaKeys.size).toBe(0) + expect(initialStateKeys.size).toBe(0) + }) + + // This is a precaution to ensure we do not attempt to access undefined properties during migrations + // If this test fails, make sure all property references to state are using optional chaining + it('uses optional chaining when accessing old state variables', async () => { + const emptyStub = { _persist: { version: -1, rehydrated: false } } + + const migrate = createMigrate(migrations) + const migratedSchema = await migrate(emptyStub, EXTENSION_STATE_VERSION) + expect(typeof migratedSchema).toBe('object') + }) + + it('migrates from initial schema to v0', () => { + const stub = { ...initialSchema } + const v0 = migrations[0](stub) + + expect(v0.wallet.isUnlocked).toBe(undefined) + }) + + it('migrates from v0 to v1', () => { + const v0Stub = { ...v0Schema } + const v1 = migrations[1](v0Stub) + + expect(v1.behaviorHistory.hasViewedUniconV2IntroModal).toBe(undefined) + }) + + it('migrates from v1 to v2', () => { + const TEST_ADDRESS = '0xTestAddress' + const txDetails0 = { + chainId: UniverseChainId.Mainnet, + id: '0', + from: '0xTestAddress', + options: { + request: { + from: '0x123', + to: '0x456', + value: '0x0', + data: '0x789', + nonce: 10, + gasPrice: BigNumber.from('10000'), + }, + }, + typeInfo: { + type: TransactionType.Approve, + tokenAddress: '0xtokenAddress', + spender: '0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45', + }, + status: TransactionStatus.Pending, + addedTime: 1487076708000, + hash: '0x123', + } + + const txDetails1 = { + ...txDetails0, + chainId: UniverseChainId.Optimism, + id: '1', + } + + const transactions = { + [TEST_ADDRESS]: { + [UniverseChainId.Mainnet]: { + '0': txDetails0, + }, + [UniverseChainId.Optimism]: { + '1': txDetails1, + }, + }, + } + + const v0stub = { ...v1Schema, transactions } + + const v64 = migrations[2](v0stub) + + expect(v64.transactions[TEST_ADDRESS][UniverseChainId.Mainnet]['0'].routing).toBe('CLASSIC') + expect(v64.transactions[TEST_ADDRESS][UniverseChainId.Optimism]['1'].routing).toBe('CLASSIC') + }) + + it('migrates from v2 to v3', () => { + const v3 = migrations[3] + testActivatePendingAccounts(v3, v2Schema) + }) + + it('migrates from v3 to v4', async () => { + const v3Stub = { ...v3Schema } + const v4 = await migrations[4](v3Stub) + expect(v4.dapp).toBe(undefined) + }) +}) diff --git a/apps/extension/src/store/migrations.ts b/apps/extension/src/store/migrations.ts new file mode 100644 index 00000000000..e0007a73fd3 --- /dev/null +++ b/apps/extension/src/store/migrations.ts @@ -0,0 +1,21 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ + +import { + activatePendingAccounts, + addRoutingFieldToTransactions, + removeUniconV2BehaviorState, + removeWalletIsUnlockedState, +} from 'wallet/src/state/sharedMigrations' + +export const migrations = { + 0: removeWalletIsUnlockedState, + 1: removeUniconV2BehaviorState, + 2: addRoutingFieldToTransactions, + 3: activatePendingAccounts, + 4: function removeDappInfoToChromLocalStorage({ dapp: _dapp, ...state }: any) { + return state + }, +} + +export const EXTENSION_STATE_VERSION = 4 diff --git a/apps/extension/src/store/reduxedChromeStorageToReduxPersistMigration.ts b/apps/extension/src/store/reduxedChromeStorageToReduxPersistMigration.ts new file mode 100644 index 00000000000..4fc83fd385b --- /dev/null +++ b/apps/extension/src/store/reduxedChromeStorageToReduxPersistMigration.ts @@ -0,0 +1,29 @@ +import { WebState } from 'src/store/webReducer' + +// TODO(EXT-1028): remove this file once the migration is no longer needed. + +const REDUXED_STORAGE_KEY = 'reduxed' + +// These functions are used to migrate the redux state persistence from `reduxed-chrome-storage` to `redux-persist`. +// The actual migration happens when the sidebar initializes the redux store. See `initializeReduxStore` in `store.ts`. + +export async function readDeprecatedReduxedChromeStorage(): Promise { + const reduxedArray = (await chrome.storage.local.get(REDUXED_STORAGE_KEY))?.[REDUXED_STORAGE_KEY] + + if (!reduxedArray) { + return undefined + } + + // The `reduxed` storage is an array: [id, timestamp, state] + const [, , state] = reduxedArray + + if (!state) { + return undefined + } + + return state as WebState +} + +export async function deleteDeprecatedReduxedChromeStorage(): Promise { + await chrome.storage.local.remove(REDUXED_STORAGE_KEY) +} diff --git a/apps/extension/src/store/schema.ts b/apps/extension/src/store/schema.ts new file mode 100644 index 00000000000..34b91a0430c --- /dev/null +++ b/apps/extension/src/store/schema.ts @@ -0,0 +1,88 @@ +// only add fields that are persisted +export const initialSchema = { + dapp: {}, + favorites: { + tokens: [], + watchedAddresses: [], + tokensVisibility: {}, + nftsVisibility: {}, + }, + notifications: { + notificationQueue: [], + notificationStatus: {}, + lastTxNotificationUpdate: {}, + }, + saga: {}, + tokens: { + dismissedWarningTokens: {}, + }, + transactions: {}, + wallet: { + accounts: {}, + activeAccountAddress: null, + hardwareDevices: [], + isUnlocked: false, + settings: { + swapProtection: 'on', + hideSmallBalances: true, + hideSpamTokens: true, + }, + }, + searchHistory: { + results: [], + }, + appearanceSettings: { + selectedAppearanceSettings: 'system', + }, + languageSettings: { + currentLanguage: 'en', + }, + fiatCurrencySettings: { + currentCurrency: 'USD', + }, + behaviorHistory: { + hasViewedReviewScreen: false, + hasSubmittedHoldToSwap: false, + hasSkippedUnitagPrompt: false, + hasCompletedUnitagsIntroModal: false, + extensionOnboardingState: 0, + }, +} + +const v0SchemaIntermediate = { + ...initialSchema, + wallet: { + ...initialSchema.wallet, + isUnlocked: undefined, + }, +} + +// We will no longer keep track of this in the redux state. +delete v0SchemaIntermediate.wallet.isUnlocked + +export const v0Schema = v0SchemaIntermediate + +const v1SchemaIntermediate = { + ...v0Schema, + behaviorHistory: { + ...v0Schema.behaviorHistory, + hasViewedUniconV2IntroModal: undefined, + }, +} + +delete v1SchemaIntermediate.behaviorHistory.hasViewedUniconV2IntroModal + +export const v1Schema = v1SchemaIntermediate +export const v2Schema = { ...v1Schema } +export const v3Schema = { ...v2Schema } + +const v4SchemaIntermediate = { + ...v3Schema, + dapp: undefined, +} + +delete v4SchemaIntermediate.dapp + +export const v4Schema = v4SchemaIntermediate + +export const getSchema = (): typeof v4Schema => v4Schema diff --git a/apps/extension/src/store/store.ts b/apps/extension/src/store/store.ts new file mode 100644 index 00000000000..93c40f09cc4 --- /dev/null +++ b/apps/extension/src/store/store.ts @@ -0,0 +1,107 @@ +import { createReduxEnhancer } from '@sentry/react' +import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux' +import { PreloadedState } from 'redux' +import { persistReducer, persistStore } from 'redux-persist' +import { localStorage } from 'redux-persist-webextension-storage' +import { webRootSaga } from 'src/app/saga' +import { loggerMiddleware } from 'src/background/utils/loggerMiddleware' +import { PERSIST_KEY } from 'src/store/constants' +import { enhancePersistReducer } from 'src/store/enhancePersistReducer' +import { EXTENSION_STATE_VERSION, migrations } from 'src/store/migrations' +import { + deleteDeprecatedReduxedChromeStorage, + readDeprecatedReduxedChromeStorage, +} from 'src/store/reduxedChromeStorageToReduxPersistMigration' +import { ReducerNames, WebState, webReducer } from 'src/store/webReducer' +import { SagaGenerator, select } from 'typed-redux-saga' +import { createStore } from 'wallet/src/state' +import { createMigrate } from 'wallet/src/state/createMigrate' +import { RootReducerNames, sharedPersistedStateWhitelist } from 'wallet/src/state/reducer' + +// Only include here things that need to be persisted and shared between different instances of the sidebar. +// Only one sidebar can write to the storage at a time, so we need to be careful about what we persist. +// Things that only belong to a single instance of the sidebar (for example, dapp requests) should not be whitelisted. +const whitelist: Array = [...sharedPersistedStateWhitelist, 'dappRequests', 'alerts'] + +const persistConfig = { + key: PERSIST_KEY, + storage: localStorage, + whitelist, + version: EXTENSION_STATE_VERSION, + migrate: createMigrate(migrations), +} + +const persistedReducer = enhancePersistReducer(persistReducer(persistConfig, webReducer)) + +const sentryReduxEnhancer = createReduxEnhancer({ + // TODO(EXT-1022): uncomment this once we add an analytics opt-out setting. + // stateTransformer: (state: WebState): Maybe => { + // Do not log the state if a user has opted out of analytics. + // if (state.telemetry.allowAnalytics) { + // return state + // } else { + // return null + // } + // }, +}) + +const setupStore = (preloadedState?: PreloadedState): ReturnType => { + return createStore({ + reducer: persistedReducer, + preloadedState, + additionalSagas: [webRootSaga], + middlewareBefore: __DEV__ ? [loggerMiddleware] : [], + enhancers: [sentryReduxEnhancer], + }) +} + +let store: ReturnType | undefined +let persistor: ReturnType | undefined + +export async function initializeReduxStore(): Promise<{ + store: ReturnType + persistor: ReturnType +}> { + // Migrate the old `reduxed-chrome-storage` persisted state to `redux-persist`. + // TODO(EXT-985): we might need to pass the old store through `createMigrations` when we implement migrations. + const oldStore = await readDeprecatedReduxedChromeStorage() + + store = setupStore(oldStore) + persistor = persistStore(store) + + // We wait a few seconds to make sure the store is fully initialized and persisted before deleting the old storage. + // This is needed because otherwise the background script might think the user is not onboarded if it reads the storage while it's being migrated. + if (oldStore) { + setTimeout(deleteDeprecatedReduxedChromeStorage, 5000) + } + + return { store, persistor } +} + +export function getReduxStore(): ReturnType { + if (!store) { + throw new Error('Invalid call to `getReduxStore` before store has been initialized') + } + return store +} + +export function getReduxPersistor(): ReturnType { + if (!persistor) { + throw new Error('Invalid call to `getReduxPersistor` before store has been initialized') + } + return persistor +} + +// TODO(EXT-1021): consider removing this helper in favor of using `useDispatch` directly. +export const useAppDispatch: () => AppDispatch = useDispatch +export const useAppSelector: TypedUseSelectorHook = useSelector + +// Use in sagas for better typing when selecting from redux state +export function* appSelect(fn: (state: WebState) => T): SagaGenerator { + const state = yield* select(fn) + return state +} + +export type AppDispatch = ReturnType['dispatch'] +export type AppStore = ReturnType +export type AppSelector = (state: WebState) => T diff --git a/apps/extension/src/store/storeSynchronization.ts b/apps/extension/src/store/storeSynchronization.ts new file mode 100644 index 00000000000..8c114e0a459 --- /dev/null +++ b/apps/extension/src/store/storeSynchronization.ts @@ -0,0 +1,156 @@ +import { useEffect, useState } from 'react' +import { getReduxPersistor, initializeReduxStore } from 'src/store/store' +import { logger } from 'utilities/src/logger/logger' +import { v4 as uuid } from 'uuid' +import { PersistedStorage } from 'wallet/src/utils/persistedStorage' + +/** + * We want only one instance of the app to be persisting the redux store to disk at a time. + * To accomplish this, we use the concept of "primary instance", which is the instance of the app that is currently being used. + * + * An instance of the app is the primary instance when: + * - It is the only instance of the app running. + * - There are multiple instances of the app running, and this is the instance of the sidebar that lives in the window that is currently (or was last) focused. + * - When there is a sidebar and an onboarding instance running on the same window, whichever is currently focused will be the primary. + */ + +const PRIMARY_APP_INSTANCE_ID_KEY = 'primaryAppInstanceId' + +const isInitialized = false +let isPrimaryAppInstance = false +const terminate: (() => Promise) | null = null + +const STORAGE_NAMESPACE = 'session' +const sessionStorage = new PersistedStorage(STORAGE_NAMESPACE) +const currentAppInstanceId = uuid() + +// These listeners are meant for `useIsPrimaryAppInstance()` to listen for changes. +const primaryAppInstanceListeners = new Set<(isPrimary: boolean) => void>() + +export enum ExtensionAppLocation { + SidePanel, + Tab, +} + +async function initPrimaryInstanceHandler(appLocation: ExtensionAppLocation): Promise { + if (isInitialized) { + // This is just to prevent bugs being introduced in the future. + logger.error(new Error('`initPrimaryInstanceHandler` called when already initialized'), { + tags: { + file: 'storeSynchronization.ts', + function: 'initPrimaryInstanceHandler', + }, + }) + return + } + + await initializeReduxStore() + + const onStorageChangedListener: Parameters[0] = async ( + changes, + namespace, + ) => { + if (namespace === STORAGE_NAMESPACE && changes[PRIMARY_APP_INSTANCE_ID_KEY]) { + const wasPrimaryAppInstance = isPrimaryAppInstance + isPrimaryAppInstance = currentAppInstanceId === changes[PRIMARY_APP_INSTANCE_ID_KEY].newValue + + if (wasPrimaryAppInstance === isPrimaryAppInstance) { + return + } + + const persistor = getReduxPersistor() + + if (isPrimaryAppInstance) { + logger.debug('store-synchronization', 'chrome.storage.onChanged', 'Resuming redux persistor') + + persistor.persist() + } else { + logger.debug('store-synchronization', 'chrome.storage.onChanged', 'Pausing redux persistor') + await persistor.flush() + persistor.pause() + } + + primaryAppInstanceListeners.forEach((listener) => listener(isPrimaryAppInstance)) + } + } + + const onFocusChangedListener: Parameters[0] = async ( + focusedWindowId, + ) => { + const { id: currentWindowId } = await chrome.windows.getCurrent() + + if (focusedWindowId === currentWindowId) { + logger.debug('store-synchronization', 'chrome.windows.onFocusChanged', 'Window focused') + await sessionStorage.setItem(PRIMARY_APP_INSTANCE_ID_KEY, currentAppInstanceId) + } + } + + const onWindowFocusListener: Parameters[1] = async () => { + // We set a slight delay to ensure that the `chrome.windows.onFocusChanged` listener runs first. + // This is to handle the case where we have a sidebar and an onboarding instance running on the same window. + setTimeout(async () => { + logger.debug('store-synchronization', 'window.onFocus', 'Window focused') + await sessionStorage.setItem(PRIMARY_APP_INSTANCE_ID_KEY, currentAppInstanceId) + }, 25) + } + + chrome.storage.onChanged.addListener(onStorageChangedListener) + + if (appLocation === ExtensionAppLocation.SidePanel) { + chrome.windows.onFocusChanged.addListener(onFocusChangedListener) + } + + window.addEventListener('focus', onWindowFocusListener) + + // We always set the current app instance as the primary when it first launches. + await sessionStorage.setItem(PRIMARY_APP_INSTANCE_ID_KEY, currentAppInstanceId) + + // This will be used in the onboarding flow when the user completes onboarding but the tab remains open. + // We don't want this tab to become the primary ever again when it's focused. + StoreSynchronization.terminate = async (): Promise => { + chrome.storage.onChanged.removeListener(onStorageChangedListener) + chrome.windows.onFocusChanged.removeListener(onFocusChangedListener) + window.removeEventListener('focus', onWindowFocusListener) + + const persistor = getReduxPersistor() + await persistor.flush() + persistor.pause() + + isPrimaryAppInstance = false + primaryAppInstanceListeners.forEach((listener) => listener(isPrimaryAppInstance)) + } +} + +export function useIsPrimaryAppInstance(): boolean { + const [isPrimary, setIsPrimary] = useState(isPrimaryAppInstance) + + useEffect(() => { + const listener = (_isPrimary: boolean): void => { + setIsPrimary(_isPrimary) + } + + primaryAppInstanceListeners.add(listener) + + return () => { + primaryAppInstanceListeners.delete(listener) + } + }, []) + + return isPrimary +} + +export function terminateStoreSynchronization(): void { + StoreSynchronization.terminate?.().catch((error) => { + logger.error(error, { + tags: { file: 'storeSynchronization.ts', function: 'useTerminateStoreSynchronization' }, + }) + }) +} + +export const StoreSynchronization: { + init: typeof initPrimaryInstanceHandler + terminate: (() => Promise) | null +} = { + init: initPrimaryInstanceHandler, + terminate, +} diff --git a/apps/extension/src/store/webReducer.ts b/apps/extension/src/store/webReducer.ts new file mode 100644 index 00000000000..2606cdc3941 --- /dev/null +++ b/apps/extension/src/store/webReducer.ts @@ -0,0 +1,20 @@ +import { combineReducers } from 'redux' +import { dappRequestReducer } from 'src/app/features/dappRequests/slice' +import { alertsReducer } from 'src/app/features/onboarding/alerts/slice' +import { popupsReducer } from 'src/app/features/popups/slice' +import { monitoredSagaReducers } from 'src/app/saga' +import { RootState } from 'wallet/src/state' +import { sharedReducers } from 'wallet/src/state/reducer' + +export const webReducers = { + ...sharedReducers, + saga: monitoredSagaReducers, + dappRequests: dappRequestReducer, + popups: popupsReducer, + alerts: alertsReducer, +} as const + +export const webReducer = combineReducers(webReducers) + +export type WebState = ReturnType & RootState +export type ReducerNames = keyof typeof webReducers diff --git a/apps/extension/src/test/__mocks__/@react-native-masked-view/masked-view.ts b/apps/extension/src/test/__mocks__/@react-native-masked-view/masked-view.ts new file mode 100644 index 00000000000..66e67ac38c1 --- /dev/null +++ b/apps/extension/src/test/__mocks__/@react-native-masked-view/masked-view.ts @@ -0,0 +1,13 @@ +import React, { PropsWithChildren, ReactNode } from 'react' +import { View, ViewProps } from 'react-native' + +// react-native-masked-view for Storybook web +// https://github.com/react-native-masked-view/masked-view/issues/70#issuecomment-1171801526 +function MaskedViewWeb({ + maskElement, + ...props +}: PropsWithChildren<{ maskElement: ReactNode }>): React.CElement { + return React.createElement(View, props, maskElement) +} + +export default MaskedViewWeb diff --git a/apps/extension/src/test/__mocks__/@shopify/react-native-skia.ts b/apps/extension/src/test/__mocks__/@shopify/react-native-skia.ts new file mode 100644 index 00000000000..766d3d19967 --- /dev/null +++ b/apps/extension/src/test/__mocks__/@shopify/react-native-skia.ts @@ -0,0 +1,19 @@ +import React, { PropsWithChildren } from 'react' +import { View, ViewProps } from 'react-native' + +// Source: https://github.com/Shopify/react-native-skia/issues/548#issuecomment-1157609472 + +const PlainView = ({ children, ...props }: PropsWithChildren): React.CElement => { + return React.createElement(View, props, children) +} +const noop = (): null => null + +export const BlurMask = PlainView +export const Canvas = PlainView +export const Circle = PlainView +export const Group = PlainView +export const LinearGradient = PlainView +export const Mask = PlainView +export const Path = PlainView +export const Rect = PlainView +export const vec = noop diff --git a/apps/extension/src/test/babel.config.js b/apps/extension/src/test/babel.config.js new file mode 100644 index 00000000000..7d99c5aa06b --- /dev/null +++ b/apps/extension/src/test/babel.config.js @@ -0,0 +1,25 @@ +// This file is used only by jest in the test environment. To check the extension +// build set up, see the webpack.config.js file. + +module.exports = function (api) { + api.cache.using(() => process.env.NODE_ENV) + var plugins = [ + "react-native-web", + [ + 'module:react-native-dotenv', + { + moduleName: 'react-native-dotenv', + path: '../../.env.defaults', + safe: true, + allowUndefined: false, + }, + ], + // https://github.com/software-mansion/react-native-reanimated/issues/3364#issuecomment-1268591867 + '@babel/plugin-proposal-export-namespace-from', + ].filter(Boolean) + + return { + presets: ['module:@react-native/babel-preset'], + plugins, + } +} diff --git a/apps/extension/src/test/fixtures/redux.ts b/apps/extension/src/test/fixtures/redux.ts new file mode 100644 index 00000000000..3a33c1cffd4 --- /dev/null +++ b/apps/extension/src/test/fixtures/redux.ts @@ -0,0 +1,13 @@ +import { PreloadedState } from 'redux' +import { WebState } from 'src/store/webReducer' +import { SharedState } from 'wallet/src/state/reducer' +import { preloadedSharedState } from 'wallet/src/test/fixtures' +import { createFixture } from 'wallet/src/test/utils' + +type PreloadedExtensionStateOptions = Record + +export const preloadedExtensionState = createFixture, PreloadedExtensionStateOptions>({})( + () => ({ + ...(preloadedSharedState() as PreloadedState), + }), +) diff --git a/apps/extension/src/test/jest-resolver.js b/apps/extension/src/test/jest-resolver.js new file mode 100644 index 00000000000..c7a1c69072d --- /dev/null +++ b/apps/extension/src/test/jest-resolver.js @@ -0,0 +1,33 @@ +const fs = require('fs') +const path = require('path') + +const platformExtensions = ['native', 'ios', 'android'] +const targetExtensions = ['web', ''] + +module.exports = (request, options) => { + const { defaultResolver } = options + const resolvedPath = defaultResolver(request, options) + + const parsedPath = path.parse(resolvedPath) + const isPlatformSpecific = platformExtensions.some((ext) => parsedPath.name.endsWith(`.${ext}`)) + + if (isPlatformSpecific) { + const index = parsedPath.name.lastIndexOf('.') + const strippedName = parsedPath.name.slice(0, index) + + for (const targetExt of targetExtensions) { + const candidatePath = path.format({ + dir: parsedPath.dir, + name: targetExt ? `${strippedName}.${targetExt}` : strippedName, + ext: parsedPath.ext, + }) + + if (fs.existsSync(candidatePath)) { + return candidatePath + } + } + } + + // Return default resolved path if no replacement is found + return resolvedPath +} diff --git a/apps/extension/src/test/render.tsx b/apps/extension/src/test/render.tsx new file mode 100644 index 00000000000..ae21971273c --- /dev/null +++ b/apps/extension/src/test/render.tsx @@ -0,0 +1,132 @@ +import type { EnhancedStore, PreloadedState } from '@reduxjs/toolkit' +import { configureStore } from '@reduxjs/toolkit' +import { + render as ReactRender, + renderHook as ReactRenderHook, + RenderHookOptions, + RenderHookResult, + RenderOptions, + RenderResult, +} from '@testing-library/react' +import React, { PropsWithChildren } from 'react' +import { AppStore } from 'src/store/store' +import { WebState, webReducer } from 'src/store/webReducer' +import { Resolvers } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' +import { UnitagUpdaterContextProvider } from 'uniswap/src/features/unitags/context' +import { SharedProvider } from 'wallet/src/provider' +import { AutoMockedApolloProvider } from 'wallet/src/test/mocks' + +// This type extends the default options for render from RTL, as well +// as allows the user to specify other things such as initialState, store. +type ExtendedRenderOptions = RenderOptions & { + resolvers?: Resolvers + preloadedState?: PreloadedState + store?: AppStore +} + +/** + * + * @param ui Component to render + * @param resolvers Custom resolvers that override the default ones + * @param preloadedState and store + * @returns `ui` wrapped with providers + */ +export function renderWithProviders( + ui: React.ReactElement, + { + resolvers, + preloadedState = {}, + // Automatically create a store instance if no store was passed in + store = configureStore({ + reducer: webReducer, + preloadedState, + middleware: (getDefaultMiddleware) => getDefaultMiddleware(), + }), + ...renderOptions + }: ExtendedRenderOptions = {}, +): RenderResult & { + store: EnhancedStore +} { + function Wrapper({ children }: PropsWithChildren): JSX.Element { + return ( + + + {children} + + + ) + } + + // Return an object with the store and all of RTL's query functions + return { store, ...ReactRender(ui, { wrapper: Wrapper, ...renderOptions }) } +} + +// This type extends the default options for render from RTL, as well +// as allows the user to specify other things such as initialState, store. +type ExtendedRenderHookOptions

= RenderHookOptions

& { + resolvers?: Resolvers + preloadedState?: PreloadedState + store?: AppStore +} + +type RenderHookWithProvidersResult = Omit, 'rerender'> & { + store: EnhancedStore + rerender: (args?: P) => void +} + +// Don't require hookOptions if hook doesn't take any arguments +export function renderHookWithProviders( + hook: () => R, + hookOptions?: ExtendedRenderHookOptions, +): RenderHookWithProvidersResult + +// Require hookOptions if hook takes arguments +export function renderHookWithProviders( + hook: (args: P) => R, + hookOptions: ExtendedRenderHookOptions

, +): RenderHookWithProvidersResult + +/** + * + * @param hook Hook to render + * @param resolvers Custom resolvers that override the default ones + * @param preloadedState and store + * @returns `hook` wrapped with providers + */ +export function renderHookWithProviders( + hook: (args: P) => R, + hookOptions?: ExtendedRenderHookOptions

, +): RenderHookWithProvidersResult { + const { + resolvers, + preloadedState = {}, + // Automatically create a store instance if no store was passed in + store = configureStore({ + reducer: webReducer, + preloadedState, + middleware: (getDefaultMiddleware) => getDefaultMiddleware(), + }), + ...renderOptions + } = (hookOptions ?? {}) as ExtendedRenderHookOptions

+ + function Wrapper({ children }: PropsWithChildren): JSX.Element { + return ( + + {children} + + ) + } + + const options: RenderHookOptions

= { + wrapper: Wrapper, + ...(renderOptions as RenderHookOptions

), + } + + const { ...rest } = ReactRenderHook((args: P) => hook(args), options) + + // Return an object with the store and all of RTL's query functions + return { + store, + ...rest, + } +} diff --git a/apps/extension/src/test/test-utils.ts b/apps/extension/src/test/test-utils.ts new file mode 100644 index 00000000000..2abe0a1491a --- /dev/null +++ b/apps/extension/src/test/test-utils.ts @@ -0,0 +1,6 @@ +import { renderHookWithProviders, renderWithProviders } from 'src/test/render' + +// re-export everything +export * from '@testing-library/react' +// override render method +export { renderWithProviders as render, renderHookWithProviders as renderHook } diff --git a/apps/extension/tsconfig.json b/apps/extension/tsconfig.json new file mode 100644 index 00000000000..c87e5f89d74 --- /dev/null +++ b/apps/extension/tsconfig.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "display": "Web App", + "extends": "tsconfig/nextjs.json", + "include": [ + "**/*.ts", + "**/*.tsx", + "**/*.json", + "../../declarations.d.ts", + ], + "exclude": [ + "node_modules" + ], + "references": [ + { + "path": "../../packages/ui" + }, + { + "path": "../../packages/utilities" + }, + { + "path": "../../packages/wallet" + } + ], + "compilerOptions": { + "baseUrl": "./", + "types": [ + "chrome", + "jest" + ] + } +} diff --git a/apps/extension/webpack.config.js b/apps/extension/webpack.config.js new file mode 100644 index 00000000000..096cb64fd24 --- /dev/null +++ b/apps/extension/webpack.config.js @@ -0,0 +1,361 @@ +const { CleanWebpackPlugin } = require('clean-webpack-plugin') +const { ProgressPlugin, ProvidePlugin, DefinePlugin } = require('webpack') +const CopyWebpackPlugin = require('copy-webpack-plugin') +const MiniCssExtractPlugin = require('mini-css-extract-plugin') +const path = require('path') +const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin') +const fs = require('fs') +const DotenvPlugin = require('dotenv-webpack') +const NodePolyfillPlugin = require('node-polyfill-webpack-plugin') +const { sentryWebpackPlugin } = require('@sentry/webpack-plugin') + +const NODE_ENV = process.env.NODE_ENV || 'development' + +// if not set tamagui wont add nice data-at, data-in etc debug attributes +process.env.NODE_ENV = NODE_ENV + +const isDevelopment = NODE_ENV === 'development' +const appDirectory = path.resolve(__dirname) +const manifest = require('./src/manifest.json') + +// Add all node modules that have to be compiled +const compileNodeModules = [ + // These libraries export JSX code from files with .js extension, which aren't transpiled + // in the library to code that doesn't use JSX syntax. This file extension is not automatically + // recognized as extension for files containing JSX, so we have to manually add them to + // the build proess (to the appropriate loader) and don't exclude them with other node_modules + 'expo-clipboard', + 'expo-linear-gradient', +] + +// This is needed for webpack to compile JavaScript. +// Many OSS React Native packages are not compiled to ES5 before being +// published. If you depend on uncompiled packages they may cause webpack build +// errors. To fix this webpack can be configured to compile to the necessary +// `node_module`. +const babelLoaderConfiguration = { + test: /\.js$/, + // Add every directory that needs to be compiled by Babel during the build. + include: [ + // path.resolve(appDirectory, "index.web.js"), + // path.resolve(appDirectory, "src"), + path.resolve(appDirectory, 'node_modules/react-native-uncompiled'), + ], + use: { + loader: 'babel-loader', + options: { + cacheDirectory: true, + // The 'metro-react-native-babel-preset' preset is recommended to match React Native's packager + presets: ['module:@react-native/babel-preset'], + // Re-write paths to import only the modules needed by the app + plugins: ['react-native-web'], + }, + }, +} + +const swcLoader = { + loader: 'swc-loader', + options: { + // parseMap: true, // required when using with babel-loader + env: { + targets: require('./package.json').browserslist, + }, + sourceMap: isDevelopment, + jsc: { + parser: { + syntax: 'typescript', + tsx: true, + dynamicImport: true, + }, + transform: { + react: { + development: isDevelopment, + refresh: isDevelopment, + }, + }, + }, + }, +} + +const swcLoaderConfiguration = { + test: ['.jsx', '.js', '.tsx', '.ts'].map((ext) => new RegExp(`${ext}$`)), + exclude: new RegExp(`node_modules/(?!(${compileNodeModules.join('|')})/)`), + use: swcLoader, +} + +const fileExtensions = ['eot', 'gif', 'jpeg', 'jpg', 'otf', 'png', 'ttf', 'woff', 'woff2', 'mp4'] + +const { + dir, + plugins = [], + ...extras +} = isDevelopment + ? { + dir: 'dev', + devServer: { + // watchFiles: ['src/**/*', 'webpack.config.js'], + host: '127.0.0.1', + port: 9997, + server: fs.existsSync('localhost.pem') + ? { + type: 'https', + options: { + key: 'localhost-key.pem', + cert: 'localhost.pem', + }, + } + : {}, + compress: false, + static: { + directory: path.join(__dirname, '../dev'), + }, + client: { + // logging: "info", + progress: true, + reconnect: false, + overlay: { + errors: true, + warnings: false, + // disable resize observer error + // NOTE: ideally would use the function format (error) => boolean + // however, I was not able to get past CSP with that solution + runtimeErrors: false, + }, + }, + devMiddleware: { + writeToDisk: true, + }, + }, + devtool: 'cheap-module-source-map', + plugins: [new ReactRefreshWebpackPlugin()], + } + : { + dir: 'build', + plugins: [], + } + +module.exports = (env) => { + // Build env is either 'dev', 'beta', or 'prod' + if (!isDevelopment && env.BUILD_ENV !== 'prod' && env.BUILD_ENV !== 'beta' && env.BUILD_ENV !== 'dev') { + throw new Error('Must set BUILD_ENV env variable to either prod, beta or dev') + } + + // Build num is the fourth number in the extension version (...). It will come from GH actions when building this to publish + if (!isDevelopment && (env.BUILD_NUM === undefined || env.BUILD_NUM < 0)) { + throw new Error('Must set BUILD_NUM env variable to a number >= 0') + } + + const BUILD_ENV = env.BUILD_ENV + const BUILD_NUM = env.BUILD_NUM || 0 + + // Title Postfix + const EXTENSION_NAME_POSTFIX = BUILD_ENV === 'dev' ? 'DEV' : BUILD_ENV === 'beta' ? 'BETA' : '' + + // Description + let EXTENSION_DESCRIPTION = manifest.description + if (BUILD_ENV === 'beta') { + EXTENSION_DESCRIPTION = 'THIS EXTENSION IS FOR BETA TESTING' + } + if (BUILD_ENV === 'dev') { + EXTENSION_DESCRIPTION = 'THIS EXTENSION IS FOR DEV TESTING' + } + + // Version + const EXTENSION_VERSION = manifest.version + '.' + BUILD_NUM + + return { + mode: NODE_ENV, + entry: { + background: './src/background/background.ts', + onboarding: './src/onboarding/onboarding.tsx', + loadSidebar: './src/sidebar/loadSidebar.ts', + sidebar: './src/sidebar/sidebar.tsx', + injected: './src/contentScript/injected.ts', + ethereum: './src/contentScript/ethereum.ts', + }, + output: { + filename: '[name].js', + chunkFilename: '[name].js', + path: path.resolve(__dirname, dir), + clean: true, + publicPath: '', + }, + // https://webpack.js.org/configuration/other-options/#level + infrastructureLogging: { level: 'warn' }, + module: { + rules: [ + // Use this rule together with other rules specified for the same pattern + { + test: /\.m?js$/, + resolve: { + fullySpecified: false, // disable the behaviour + }, + }, + { + oneOf: [ + { + test: /\.(woff|woff2)$/, + use: { loader: 'file-loader' }, + }, + + { + test: /\.css$/, + use: [ + { + loader: 'style-loader', + }, + { + loader: 'css-loader', + }, + ], + }, + + { + type: 'javascript/auto', + test: /\.json$/, + use: ['file-loader'], + include: /tokenlist/, + }, + + // Used for creating SVG React components (similar to react=native-svg-transformer on mobile) + { + test: /\.svg$/, + use: ['@svgr/webpack'], + }, + + { + test: new RegExp('.(' + fileExtensions.join('|') + ')$'), + type: 'asset/resource', + }, + + { + test: /.tsx?$/, + exclude: (file) => file.includes('node_modules'), + use: [ + // one after to remove the jsx + swcLoader, + + // tamagui optimizes the jsx + { + loader: 'tamagui-loader', + options: { + config: '../../packages/ui/src/tamagui.config.ts', + components: ['ui'], + // add files here that should be parsed by the compiler from within any of the apps/* + // for example if you have constants.ts then constants.js goes here and it will eval them + // at build time and if it can flatten views even if they use imports from that file + importsWhitelist: ['constants.js'], + disableExtraction: process.env.NODE_ENV === 'development', + }, + }, + + // one before to remove types + { + loader: 'esbuild-loader', + options: { + target: 'es2022', + jsx: 'preserve', + minify: false, + }, + }, + ], + }, + + babelLoaderConfiguration, + swcLoaderConfiguration, + ], + }, + ], + }, + resolve: { + alias: { + 'react-native$': 'react-native-web', + 'react-native-reanimated$': require.resolve('react-native-reanimated'), + 'react-native-vector-icons$': 'react-native-vector-icons/dist', + src: path.resolve(__dirname, 'src'), // absolute imports in apps/web + 'react-native-gesture-handler$': require.resolve('react-native-gesture-handler'), + }, + // Add support for web-based extensions so we can share code between mobile/extension + extensions: [ + '.web.js', + '.web.jsx', + '.web.ts', + '.web.tsx', + ...fileExtensions.map((e) => `.${e}`), + ...['.js', '.jsx', '.ts', '.tsx', '.css'], + ], + fallback: { + fs: false, + }, + }, + devtool: 'source-map', + plugins: [ + new DotenvPlugin({ + path: '../../.env', + defaults: true, + }), + new DefinePlugin({ + __DEV__: NODE_ENV === 'development' ? 'true' : 'false', + 'process.env.IS_STATIC': '""', + 'process.env.NODE_ENV': JSON.stringify(NODE_ENV), + 'process.env.DEBUG': JSON.stringify(process.env.DEBUG || '0'), + 'process.env.VERSION': JSON.stringify(EXTENSION_VERSION), + 'process.env.IS_UNISWAP_EXTENSION': '"true"', + }), + new CleanWebpackPlugin(), + new NodePolyfillPlugin(), // necessary to compile with reactnative-dotenv + ...plugins, + new MiniCssExtractPlugin(), + new ProgressPlugin(), + new ProvidePlugin({ + process: 'process/browser', + React: 'react', + Buffer: ['buffer', 'Buffer'], + }), + new CopyWebpackPlugin({ + patterns: [ + { + from: 'src/manifest.json', + force: true, + transform(content) { + return Buffer.from( + JSON.stringify( + { + ...manifest, + description: EXTENSION_DESCRIPTION, + version: EXTENSION_VERSION, + name: EXTENSION_NAME_POSTFIX ? manifest.name + ' ' + EXTENSION_NAME_POSTFIX : manifest.name, + }, + null, + 2, + ), + ) + }, + }, + { + from: 'src/assets/fonts/*.{woff,woff2,ttf}', + to: 'assets/fonts/[name][ext]', + force: true, + }, + { + from: 'src/assets/*.{html,png,svg}', + to: 'assets/[name][ext]', + force: true, + }, + { + from: 'src/*.{html,png,svg}', + to: '[name][ext]', + force: true, + }, + ], + }), + sentryWebpackPlugin({ + authToken: env.SENTRY_AUTH_TOKEN, + org: 'uniswap-labs', + project: 'extension-wallet', + telemetry: process.env.NODE_ENV === 'production', + }), + ], + ...extras, + } +} diff --git a/apps/mobile/__mocks__/@react-navigation/native.js b/apps/mobile/__mocks__/@react-navigation/native.js deleted file mode 100644 index ce368193060..00000000000 --- a/apps/mobile/__mocks__/@react-navigation/native.js +++ /dev/null @@ -1,30 +0,0 @@ -// Copied from: -// https://gist.github.com/phcbarros/bd90825863c3573cc0a28e90db17d1a4 -const RNN = require('@react-navigation/native') -let listeners = {} -const setOptions = jest.fn() -const navigate = jest.fn() - -const navigation = { - setOptions, - navigate, - addListener: jest.fn((name, l) => (listeners[name] = l)), - getListener: (name) => listeners[name], - triggerListener: (name, ...params) => listeners[name](...params), - resetListeners: () => { - listeners = {} - }, -} - -const useNavigation = () => navigation -let params = {} -const useRoute = () => ({ - params, -}) - -module.exports = { - ...RNN, - useNavigation, - useRoute, - setParams: (p) => (params = { ...params, ...p }), -} diff --git a/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/import/SeedPhraseInput.kt b/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/import/SeedPhraseInput.kt index 0770108f68b..7f7bda84d6d 100644 --- a/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/import/SeedPhraseInput.kt +++ b/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/import/SeedPhraseInput.kt @@ -25,7 +25,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester @@ -142,29 +141,26 @@ fun SeedPhraseInput( private fun SeedPhraseError(viewModel: SeedPhraseInputViewModel) { val status = viewModel.status val rnStrings = viewModel.rnStrings - var text = "" if (status is Error) { - text = when (val error = status.error) { + val text = when (val error = status.error) { is InvalidWord -> "${rnStrings.errorInvalidWord} ${error.word}" is NotEnoughWords, TooManyWords -> rnStrings.errorPhraseLength is WrongRecoveryPhrase -> rnStrings.errorWrongPhrase is InvalidPhrase -> rnStrings.errorInvalidPhrase } - } - - Row( - horizontalArrangement = Arrangement.spacedBy(UniswapTheme.spacing.spacing4), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.alpha(if (text.isEmpty()) 0f else 1f) - ) { - Icon( - painter = painterResource(id = R.drawable.uniswap_icon_alert_triangle), - tint = UniswapTheme.colors.statusCritical, - contentDescription = null, - modifier = Modifier.size(16.dp) - ) - Text(text, style = UniswapTheme.typography.body3, color = UniswapTheme.colors.statusCritical) + Row( + horizontalArrangement = Arrangement.spacedBy(UniswapTheme.spacing.spacing4), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(id = R.drawable.uniswap_icon_alert_triangle), + tint = UniswapTheme.colors.statusCritical, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Text(text, style = UniswapTheme.typography.body3, color = UniswapTheme.colors.statusCritical) + } } } diff --git a/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/import/SeedPhraseInputViewModel.kt b/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/import/SeedPhraseInputViewModel.kt index f9d839e1dda..32768d11bf8 100644 --- a/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/import/SeedPhraseInputViewModel.kt +++ b/apps/mobile/android/app/src/main/java/com/uniswap/onboarding/import/SeedPhraseInputViewModel.kt @@ -6,13 +6,8 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import com.uniswap.EthersRs import com.uniswap.RnEthersRs -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch class SeedPhraseInputViewModel( private val ethersRs: RnEthersRs, @@ -60,26 +55,13 @@ class SeedPhraseInputViewModel( private set var status by mutableStateOf(Status.None) private set - private var validateLastWordTimeout: Long = 1000 - private var validateLastWordJob: Job? = null fun handleInputChange(value: TextFieldValue) { input = value val normalized = normalizeInput(value) val skipLastWord = normalized.lastOrNull() != ' ' - validateInput(normalized, skipLastWord) - - validateLastWordJob?.cancel() - - validateLastWordJob = viewModelScope.launch(Dispatchers.Default) { - delay(validateLastWordTimeout) - validateInput(normalized, false) - } - } - - private fun validateInput(normalizedInput: String, skipLastWord: Boolean) { - val mnemonic = normalizedInput.trim() + val mnemonic = normalized.trim() val words = mnemonic.split(" ") if (words.isEmpty()) { @@ -89,14 +71,14 @@ class SeedPhraseInputViewModel( val isValidLength = words.size in MIN_LENGTH..MAX_LENGTH val firstInvalidWord = EthersRs.findInvalidWord(mnemonic) - status = if (firstInvalidWord == words.last() && skipLastWord) { - Status.None + if (firstInvalidWord == words.last() && skipLastWord) { + status = Status.None } else if (firstInvalidWord.isEmpty() && isValidLength) { - Status.Valid + status = Status.Valid } else if (firstInvalidWord.isNotEmpty()) { - Status.Error(MnemonicError.InvalidWord(firstInvalidWord)) + status = Status.Error(MnemonicError.InvalidWord(firstInvalidWord)) } else { - Status.None + status = Status.None } val canSubmit = status !is Status.Error && mnemonic != "" && firstInvalidWord.isEmpty() diff --git a/apps/mobile/android/settings.gradle b/apps/mobile/android/settings.gradle index abce13dff8c..556232de3e9 100644 --- a/apps/mobile/android/settings.gradle +++ b/apps/mobile/android/settings.gradle @@ -8,6 +8,6 @@ apply from: new File(["node", "--print", "require.resolve('../../../node_modules useExpoModules() include ':@sentry_react-native' - +project(':@sentry_react-native').projectDir = new File('../../../node_modules/@sentry/react-native/android') include ':detox' project(':detox').projectDir = new File('../../../node_modules/detox/android/detox') diff --git a/apps/mobile/e2e/Home.e2e.ts b/apps/mobile/e2e/Home.e2e.ts deleted file mode 100644 index b3ecd4a6112..00000000000 --- a/apps/mobile/e2e/Home.e2e.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { HomeBasicInteractions } from 'e2e/usecases/home/HomeBasicInteractions' -import { WatchWallet } from 'e2e/usecases/onboarding/WatchWallet' - -describe('Home', () => { - beforeEach(async () => { - await device.launchApp() - await WatchWallet() - }) - - it('tests basic home screen interactions', HomeBasicInteractions) -}) diff --git a/apps/mobile/e2e/Onboarding.e2e.ts b/apps/mobile/e2e/Onboarding.e2e.ts index 8a23dd2871b..efb67731ed3 100644 --- a/apps/mobile/e2e/Onboarding.e2e.ts +++ b/apps/mobile/e2e/Onboarding.e2e.ts @@ -1,5 +1,4 @@ import { CreateNewWallet } from 'e2e/usecases/onboarding/CreateNewWallet' -import { ImportWallet } from 'e2e/usecases/onboarding/ImportWallet' import { WatchWallet } from 'e2e/usecases/onboarding/WatchWallet' describe('Onboarding', () => { @@ -8,12 +7,13 @@ describe('Onboarding', () => { }) afterEach(async () => { - await device.clearKeychain() await device.uninstallApp() await device.installApp() }) it('creates a new wallet', CreateNewWallet) it('watches wallet', WatchWallet) - it('imports a testing wallet using recovery phrase', ImportWallet) + // TODO: find the way to test native input + // eslint-disable-next-line jest/no-commented-out-tests + // it('imports a testing wallet using recovery phrase', ImportWallet) }) diff --git a/apps/mobile/e2e/README.md b/apps/mobile/e2e/README.md index 881e91cc6d2..89b2fc88a4e 100644 --- a/apps/mobile/e2e/README.md +++ b/apps/mobile/e2e/README.md @@ -29,12 +29,6 @@ Run ios e2e tests in debug mode: yarn mobile e2e:ios:test:debug ``` -Useful perameters: - -`--testNamePattern test-name` to run a single test, replace `test-name` with test file name without extension e.g.: `Swap` or `Onboarding`. - -`--reuse` to start the test from a current app state. Useful for testing nested screen behaviour without going through onboarding and navigation steps. - #### Release mode To run tests in release mode: @@ -51,10 +45,6 @@ E2E tests should remain as close as possible to production, but sometimes mockin Only mocking entire files is supported at the moment, so you may need to reorganize functions. To mock a file, create a new one with the same name and extension `mock.ts` (e.g. `AnimatedHeader.ts` -> `AnimatedHeader.mock.ts`) in the same directory. The metro bundler will override any file that has a `mock.ts` equivalent in Detox runs. -Android native views based on jetpack compose and libraries utilizing long-running asynchronouse background processes like sentry are not supported by detox currently. Imports mocking is unfortunatelly not supported by detox yet. If such problems occur, the entire component using problematic library needs to be mocked or a component exposing only targeted library needs to be created and then it can be mocked, precisely replacing only targeted library. - -To mock a component for specific platform follow this pattern: -iOS: `AnimatedHeader.ts` -> `AnimatedHeader.ios.mock.ts` -Android: `AnimatedHeader.ts` -> `AnimatedHeader.android.mock.ts` +Native views, libraries relying on the native code and libraries utilizing long-running asynchronouse background processes like sentry are not supported by detox currently. Imports mocking is unfortunatelly not supported by detox yet. If such problems occur, the entire component using problematic library needs to be mocked or a component exposing only targeted library needs to be created and then it can be mocked, precisely replacing only targeted library. Read more here https://wix.github.io/Detox/docs/guide/mocking/ diff --git a/apps/mobile/e2e/usecases/home/HomeBasicInteractions.ts b/apps/mobile/e2e/usecases/home/HomeBasicInteractions.ts deleted file mode 100644 index 339b10d1cd1..00000000000 --- a/apps/mobile/e2e/usecases/home/HomeBasicInteractions.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { by, element, expect } from 'detox' -import { TestWatchedWallet } from 'e2e/utils/fixtures' -import { TestID } from 'uniswap/src/test/fixtures/testIDs' - -export async function HomeBasicInteractions(): Promise { - await expect(element(by.text(TestWatchedWallet.displayName))).toBeVisible() - await expect(element(by.id(TestID.Swap))).toBeVisible() - await expect(element(by.id(TestID.SearchTokensAndWallets))).toBeVisible() - - // opens AccountSwitcherModal by clicking on account avatar - await expect(element(by.id(TestID.AccountHeaderAvatar))).toBeVisible() - - // checks if portfolio balance is visible - await expect(element(by.id(TestID.PortfolioBalance))).toBeVisible() - - // copies wallet address from AccountSwitcherModal - await element(by.id(TestID.AccountHeaderCopyAddress)).tap() - - // checks if notification toast is visible with title "Address copied" - await expect(element(by.id(TestID.NotificationToastTitle))).toBeVisible() - await expect(element(by.id(TestID.NotificationToastTitle))).toHaveText('Address copied') - - // checks if list was rendered properly by checking if the first item is visible - await expect(element(by.id('token-list-item-0'))).toBeVisible() - - // scrolls to the bottom of the token list - await element(by.id('token-list-item-0')).swipe('up') - - // checks if only tabs headers are visible then scrolled to bottom - await expect(element(by.id(TestID.AccountHeaderAvatar))).not.toBeVisible() - await expect(element(by.id(TestID.PortfolioBalance))).not.toBeVisible() - // for some reason react-native-tab-view renders headers twice, thats why first matching item was picked - await expect(element(by.id('home-tab-Tokens')).atIndex(0)).toBeVisible() - await expect(element(by.id('home-tab-NFTs')).atIndex(0)).toBeVisible() - await expect(element(by.id('home-tab-Activity')).atIndex(0)).toBeVisible() - - // checks if the first item of hidden list is not visible - await expect(element(by.id('token-list-item-0'))).not.toBeVisible() - - // hidden item does not exist - await expect(element(by.id('token-list-item-25'))).not.toExist() - - // taps on "show" button to show hidden elements - await element(by.id(TestID.ShowHiddenTokens)).tap() - - // checks if first hidden element is visible - await expect(element(by.id('token-list-item-25'))).toExist() - - // taps on "hide" button to show hidden elements - await element(by.id(TestID.ShowHiddenTokens)).tap() - - // checks if first item of the hidden item is not visible again - await expect(element(by.id('token-list-item-25'))).not.toExist() - - // switches to NFTs tab - await element(by.id('home-tab-NFTs')).atIndex(0).tap() - - // checks is if tokens are visible - await expect(element(by.id('nfts-list-item-0'))).toBeVisible() - - // switches to Activity tab - await element(by.id('home-tab-Activity')).atIndex(0).tap() - - // checks is if tokens are visible - await expect(element(by.id('activity-list-item-0'))).toBeVisible() - - // switches back to tokens tab - await element(by.id('home-tab-Tokens')).atIndex(0).tap() - - // scrolls to the bottom of the token list - await element(by.id('token-list-item-16')).swipe('down') - - // checks if list of tokens was rendered properly by checking first token visibility - await expect(element(by.id('token-list-item-0'))).toBeVisible() -} diff --git a/apps/mobile/e2e/usecases/onboarding/ImportWallet.ts b/apps/mobile/e2e/usecases/onboarding/ImportWallet.ts index 539b64cb548..70b742b7a34 100644 --- a/apps/mobile/e2e/usecases/onboarding/ImportWallet.ts +++ b/apps/mobile/e2e/usecases/onboarding/ImportWallet.ts @@ -10,6 +10,7 @@ export async function ImportWallet(): Promise { await element(by.id(TestID.OnboardingImportSeedPhrase)).tap() // Checks if recovery phase input is in focus and types recovery phrase in + await expect(element(by.id(TestID.ImportAccountInput))).toBeFocused() await element(by.id(TestID.ImportAccountInput)).typeText(TestWallet.recoveryPhrase) // Taps continue navigating to SelectWalletScreen diff --git a/apps/mobile/e2e/usecases/swap/SwapBasicInteractions.ts b/apps/mobile/e2e/usecases/swap/SwapBasicInteractions.ts index 7d7414f0c65..4fb54b3deef 100644 --- a/apps/mobile/e2e/usecases/swap/SwapBasicInteractions.ts +++ b/apps/mobile/e2e/usecases/swap/SwapBasicInteractions.ts @@ -18,43 +18,41 @@ export async function SwapBasicInteractions(): Promise { // Picks usdc output token await element(by.text('USDC')).atIndex(0).tap() - // Taps .98765432101 into the swap input - await element(by.id('decimal-pad-.')).tap() - await element(by.id('decimal-pad-9')).tap() - await element(by.id('decimal-pad-8')).tap() - await element(by.id('decimal-pad-7')).tap() - await element(by.id('decimal-pad-6')).tap() - await element(by.id('decimal-pad-5')).tap() - await element(by.id('decimal-pad-4')).tap() - await element(by.id('decimal-pad-3')).tap() - await element(by.id('decimal-pad-2')).tap() + // Taps 1234567890 number into swap input await element(by.id('decimal-pad-1')).tap() + await element(by.id('decimal-pad-2')).tap() + await element(by.id('decimal-pad-3')).tap() + await element(by.id('decimal-pad-4')).tap() + await element(by.id('decimal-pad-5')).tap() + await element(by.id('decimal-pad-6')).tap() + await element(by.id('decimal-pad-7')).tap() + await element(by.id('decimal-pad-8')).tap() + await element(by.id('decimal-pad-.')).tap() await element(by.id('decimal-pad-0')).tap() + await element(by.id('decimal-pad-9')).tap() await element(by.id('decimal-pad-1')).tap() - - // Taps a backspace button leaving .9876543210 value in the input field await element(by.id('decimal-pad-backspace')).tap() - // Checks if expected input expected value: ".9876543210" - await expect(element(by.id(TestID.AmountInputIn))).toHaveText('.9876543210') + // Checks if expected input expected value: "12345678.09" + await expect(element(by.id(TestID.AmountInputIn))).toHaveValue('12345678.09') // Checks if expected error is displayed await expect(element(by.text('You don’t have enough ETH'))).toBeVisible() // Checks if expected output expected value: "0" - await expect(element(by.id(TestID.AmountInputOut))).not.toHaveText('0') + await expect(element(by.id(TestID.AmountInputOut))).not.toHaveValue('0') // Swaps input and output currencies await element(by.id(TestID.SwitchCurrenciesButton)).tap() // Checks if expected input expected value: "0" - await expect(element(by.id(TestID.AmountInputIn))).not.toHaveText('0') + await expect(element(by.id(TestID.AmountInputIn))).toHaveValue('0') // Checks if expected error is displayed - await expect(element(by.text('You don’t have enough USDC'))).toBeVisible() + await expect(element(by.text('Not enough liquidity'))).toBeVisible() - // Checks if expected output expected value: ".9876543210" - await expect(element(by.id(TestID.AmountInputOut))).toHaveText('.9876543210') + // Checks if expected output expected value: "12345678.09" + await expect(element(by.id(TestID.AmountInputOut))).toHaveValue('12345678.09') // Swaps input and output currencies await element(by.id(TestID.SwitchCurrenciesButton)).tap() @@ -70,10 +68,10 @@ export async function SwapBasicInteractions(): Promise { await element(by.id('decimal-pad-3')).tap() // Checks if output has expected value: "123" - await expect(element(by.id(TestID.AmountInputOut))).toHaveText('123') + await expect(element(by.id(TestID.AmountInputOut))).toHaveValue('123') // Checks if expected input value to be cleared - await expect(element(by.id(TestID.AmountInputIn))).not.toHaveText('0') + await expect(element(by.id(TestID.AmountInputIn))).not.toHaveValue('0') // Checks dollar value to be visible await expect(element(by.text('$123.00'))).toBeVisible() diff --git a/apps/mobile/ios/Uniswap/Onboarding/Import/SeedPhraseInputManager.m b/apps/mobile/ios/Uniswap/Onboarding/Import/SeedPhraseInputManager.m index 71430dc2682..2342c17486a 100644 --- a/apps/mobile/ios/Uniswap/Onboarding/Import/SeedPhraseInputManager.m +++ b/apps/mobile/ios/Uniswap/Onboarding/Import/SeedPhraseInputManager.m @@ -16,7 +16,6 @@ @interface RCT_EXTERN_MODULE(SeedPhraseInputManager, RCTViewManager) RCT_EXPORT_VIEW_PROPERTY(onPasteStart, RCTDirectEventBlock); RCT_EXPORT_VIEW_PROPERTY(onPasteEnd, RCTDirectEventBlock); RCT_EXPORT_VIEW_PROPERTY(onHeightMeasured, RCTDirectEventBlock); -RCT_EXPORT_VIEW_PROPERTY(testID, NSString?) RCT_EXTERN_METHOD(handleSubmit: (nonnull NSNumber *)node) @end diff --git a/apps/mobile/ios/Uniswap/Onboarding/Import/SeedPhraseInputView.swift b/apps/mobile/ios/Uniswap/Onboarding/Import/SeedPhraseInputView.swift index 55781b4ef48..f2dda700695 100644 --- a/apps/mobile/ios/Uniswap/Onboarding/Import/SeedPhraseInputView.swift +++ b/apps/mobile/ios/Uniswap/Onboarding/Import/SeedPhraseInputView.swift @@ -78,12 +78,6 @@ class SeedPhraseInputView: UIView { set { vc.rootView.viewModel.onHeightMeasured = newValue } get { return vc.rootView.viewModel.onHeightMeasured } } - - @objc - var testID: String? { - get { vc.rootView.viewModel.testID } - set { vc.rootView.viewModel.testID = newValue } - } @objc var handleSubmit: () -> Void { @@ -118,14 +112,12 @@ struct SeedPhraseInput: View { VStack(spacing: 12) { VStack { VStack { - ZStack(alignment: .topLeading) { + ZStack(alignment: .leading) { TextEditor(text: $viewModel.input) .focused($focused) .autocorrectionDisabled() .textInputAutocapitalization(.never) .modifier(TextEditModifier()) - .frame(minHeight: 96) // 120 - 2 * 12 for padding - .accessibility(identifier: viewModel.testID ?? "import-account-input") if (viewModel.input.isEmpty) { Text(viewModel.strings.inputPlaceholder) @@ -139,6 +131,7 @@ struct SeedPhraseInput: View { .fixedSize(horizontal: false, vertical: true) .background(Colors.surface1) .padding(12) // Adds to default TextEditor padding 8 + .frame(minHeight: 120, alignment: .top) .cornerRadius(16) .overlay( RoundedRectangle(cornerRadius: 16) @@ -148,7 +141,7 @@ struct SeedPhraseInput: View { .onTapGesture { focused = true } - .onAppear { + .onAppear() { DispatchQueue.main.async { focused = true } @@ -196,7 +189,7 @@ struct SeedPhraseInput: View { } ) } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .frame(maxWidth:.infinity, maxHeight: .infinity, alignment: .top) .font(font) } diff --git a/apps/mobile/ios/Uniswap/Onboarding/Import/SeedPhraseInputViewModel.swift b/apps/mobile/ios/Uniswap/Onboarding/Import/SeedPhraseInputViewModel.swift index b6d9f2fc896..62432057f05 100644 --- a/apps/mobile/ios/Uniswap/Onboarding/Import/SeedPhraseInputViewModel.swift +++ b/apps/mobile/ios/Uniswap/Onboarding/Import/SeedPhraseInputViewModel.swift @@ -36,9 +36,6 @@ class SeedPhraseInputViewModel: ObservableObject { // Following block of variables will come from RN @Published var targetMnemonicId: String? = nil - - @Published var testID: String? = nil - @Published var rawRNStrings: Dictionary = Dictionary() { didSet { strings = ReactNativeStrings( @@ -65,15 +62,11 @@ class SeedPhraseInputViewModel: ObservableObject { @Published var onPasteEnd: RCTDirectEventBlock = { _ in } @Published var onHeightMeasured: RCTDirectEventBlock = { _ in } - private var lastWordValidationTimer: Timer? - private let lastWordValidationTimeout: TimeInterval = 1.0 - @Published var input = "" { didSet { - handleInputChange() + validateInput() } } - @Published var skipLastWord = true @Published var status: Status = .none @Published var error: MnemonicError? = nil @@ -142,26 +135,10 @@ class SeedPhraseInputViewModel: ObservableObject { return value.trimmingCharacters(in: .whitespacesAndNewlines) } - private func handleInputChange() { + private func validateInput() { let normalized = normalizeInput(value: input) let skipLastWord = normalized.last != " " - validateInput(normalizedInput: normalized, skipLastWord: skipLastWord) - - lastWordValidationTimer?.invalidate() - - if (skipLastWord) { - lastWordValidationTimer = Timer.scheduledTimer( - withTimeInterval: lastWordValidationTimeout, - repeats: false) { _ in - DispatchQueue.global(qos: .background).async { - self.validateInput(normalizedInput: normalized, skipLastWord: false) - } - } - } - } - - private func validateInput(normalizedInput: String, skipLastWord: Bool) { - let mnemonic = trimInput(value: normalizedInput) + let mnemonic = trimInput(value: normalized) let words = mnemonic.components(separatedBy: " ") diff --git a/apps/mobile/jest-setup.js b/apps/mobile/jest-setup.js index eead5ec6431..084c8648abc 100644 --- a/apps/mobile/jest-setup.js +++ b/apps/mobile/jest-setup.js @@ -122,3 +122,8 @@ jest.mock('wallet/src/features/appearance/hooks', () => { useSelectedColorScheme: () => 'light', } }) + +jest.mock('wallet/src/features/fiatOnRamp/api', () => ({ + ...jest.requireActual('wallet/src/features/fiatOnRamp/api'), + useFiatOnRampIpAddressQuery: jest.fn().mockReturnValue({}), +})) diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 26f39ae3b5a..4cc4190305a 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -33,7 +33,7 @@ "link:assets": "react-native-asset", "graphql:generate:swift": "cd ios && ./Pods/Apollo/apollo-ios-cli generate", "hardhat": "hardhat node", - "check:circular": "../../scripts/check-circular-imports.sh ./src/app/App.tsx 5", + "check:circular": "../../scripts/check-circular-imports.sh ./src/app/App.tsx 6", "ios": "yarn ios:prebuild && SKIP_BUNDLING=1 react-native run-ios", "ios:prebuild": "yarn graphql:generate:swift && cd ios/WidgetsCore/MobileSchema && rm -rf !(README.md) && cd ../../.. && yarn graphql:generate:swift && yarn env:local:copy:swift", "ios:smol": "SKIP_BUNDLING=1 react-native run-ios --simulator=\"iPhone SE (3rd generation)\"", @@ -83,7 +83,7 @@ "@shopify/react-native-performance-navigation": "3.0.0", "@shopify/react-native-skia": "1.2.0", "@uniswap/analytics": "1.7.0", - "@uniswap/analytics-events": "2.34.0", + "@uniswap/analytics-events": "2.32.0", "@uniswap/ethers-rs-mobile": "0.0.5", "@uniswap/sdk-core": "5.3.0", "@uniswap/v3-sdk": "3.13.0", @@ -171,7 +171,7 @@ "babel-plugin-module-resolver": "5.0.0", "babel-plugin-react-native-web": "0.17.5", "core-js": "2.6.12", - "detox": "20.23.0", + "detox": "20.18.1", "eslint": "8.44.0", "expo-modules-core": "1.11.13", "hardhat": "2.14.0", diff --git a/apps/mobile/scripts/podinstall.sh b/apps/mobile/scripts/podinstall.sh index fc7d144225a..e6b5f42feaa 100755 --- a/apps/mobile/scripts/podinstall.sh +++ b/apps/mobile/scripts/podinstall.sh @@ -1,26 +1,2 @@ #!/bin/bash - -set -e - -REQUIRED_XCODE_VERSION="15.2" - -check_xcode_version() { - local current_version=$(xcodebuild -version | grep "Xcode" | cut -d' ' -f2) - if [ "$current_version" != "$REQUIRED_XCODE_VERSION" ]; then - echo "Error: Xcode version mismatch" - echo "Required: $REQUIRED_XCODE_VERSION" - echo "Current: $current_version" - exit 1 - fi - echo "Xcode version check passed: $current_version" -} - -# Check Xcode version -check_xcode_version - -# Install pods -cd ios/ -bundle install -bundle exec pod install -cd .. - +cd ios/ && bundle install && bundle exec pod install && cd .. diff --git a/apps/mobile/src/app/App.tsx b/apps/mobile/src/app/App.tsx index 95b75963cda..8e1520e9ac3 100644 --- a/apps/mobile/src/app/App.tsx +++ b/apps/mobile/src/app/App.tsx @@ -12,10 +12,9 @@ import { GestureHandlerRootView } from 'react-native-gesture-handler' import { MMKV } from 'react-native-mmkv' import { SafeAreaProvider } from 'react-native-safe-area-context' import { enableFreeze } from 'react-native-screens' -import { useDispatch } from 'react-redux' import { PersistGate } from 'redux-persist/integration/react' import { MobileWalletNavigationProvider } from 'src/app/MobileWalletNavigationProvider' -import { useAppSelector } from 'src/app/hooks' +import { useAppDispatch, useAppSelector } from 'src/app/hooks' import { AppModals } from 'src/app/modals/AppModals' import { NavigationContainer } from 'src/app/navigation/NavigationContainer' import { useIsPartOfNavigationTree } from 'src/app/navigation/hooks' @@ -256,7 +255,7 @@ function AppOuter(): JSX.Element | null { } function AppInner(): JSX.Element { - const dispatch = useDispatch() + const dispatch = useAppDispatch() const isDarkMode = useIsDarkMode() const themeSetting = useCurrentAppearanceSetting() const allowAnalytics = useAppSelector(selectAllowAnalytics) diff --git a/apps/mobile/src/app/MobileWalletNavigationProvider.tsx b/apps/mobile/src/app/MobileWalletNavigationProvider.tsx index 567bc98e84b..f39d6ceb534 100644 --- a/apps/mobile/src/app/MobileWalletNavigationProvider.tsx +++ b/apps/mobile/src/app/MobileWalletNavigationProvider.tsx @@ -1,6 +1,6 @@ import { PropsWithChildren, useCallback } from 'react' import { Share } from 'react-native' -import { useDispatch } from 'react-redux' +import { useAppDispatch } from 'src/app/hooks' import { exploreNavigationRef } from 'src/app/navigation/navigation' import { useAppStackNavigation } from 'src/app/navigation/types' import { closeModal, openModal } from 'src/features/modals/modalSlice' @@ -24,6 +24,7 @@ import { getNavigateToSendFlowArgsInitialState, getNavigateToSwapFlowArgsInitialState, } from 'wallet/src/contexts/WalletNavigationContext' +import { useFiatOnRampIpAddressQuery } from 'wallet/src/features/fiatOnRamp/api' import { getNftUrl, getTokenUrl } from 'wallet/src/utils/linking' export function MobileWalletNavigationProvider({ children }: PropsWithChildren): JSX.Element { @@ -113,7 +114,7 @@ function useNavigateToHomepageTab(tab: HomeScreenTabIndex): () => void { } function useNavigateToReceive(): () => void { - const dispatch = useDispatch() + const dispatch = useAppDispatch() return useCallback((): void => { dispatch(openModal({ name: ModalName.WalletConnectScan, initialState: ScannerModalState.WalletQr })) @@ -121,7 +122,7 @@ function useNavigateToReceive(): () => void { } function useNavigateToSend(): (args: NavigateToSendFlowArgs) => void { - const dispatch = useDispatch() + const dispatch = useAppDispatch() return useCallback( (args: NavigateToSendFlowArgs) => { @@ -133,7 +134,7 @@ function useNavigateToSend(): (args: NavigateToSendFlowArgs) => void { } function useNavigateToSwapFlow(): (args: NavigateToSwapFlowArgs) => void { - const dispatch = useDispatch() + const dispatch = useAppDispatch() return useCallback( (args: NavigateToSwapFlowArgs): void => { @@ -191,8 +192,10 @@ function useNavigateToNftCollection(): (args: NavigateToNftCollectionArgs) => vo } function useNavigateToBuyOrReceiveWithEmptyWallet(): () => void { - const dispatch = useDispatch() - // This flag is enabled only for supported countries. + const dispatch = useAppDispatch() + + const { data } = useFiatOnRampIpAddressQuery() + const moonpayFiatOnRampEligible = Boolean(data?.isBuyAllowed) const forAggregatorEnabled = useFeatureFlag(FeatureFlags.ForAggregator) return useCallback((): void => { @@ -200,6 +203,8 @@ function useNavigateToBuyOrReceiveWithEmptyWallet(): () => void { if (forAggregatorEnabled) { dispatch(openModal({ name: ModalName.FiatOnRampAggregator })) + } else if (moonpayFiatOnRampEligible) { + dispatch(openModal({ name: ModalName.FiatOnRamp })) } else { dispatch( openModal({ @@ -208,5 +213,5 @@ function useNavigateToBuyOrReceiveWithEmptyWallet(): () => void { }), ) } - }, [dispatch, forAggregatorEnabled]) + }, [dispatch, forAggregatorEnabled, moonpayFiatOnRampEligible]) } diff --git a/apps/mobile/src/app/hooks.ts b/apps/mobile/src/app/hooks.ts index 639606da04f..050c29e024b 100644 --- a/apps/mobile/src/app/hooks.ts +++ b/apps/mobile/src/app/hooks.ts @@ -1,13 +1,16 @@ import { useFocusEffect } from '@react-navigation/core' +import { ThunkDispatch } from '@reduxjs/toolkit' import { useCallback, useRef, useState } from 'react' import { LayoutChangeEvent } from 'react-native' -import { TypedUseSelectorHook, useSelector } from 'react-redux' +import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux' import type { MobileState } from 'src/app/reducer' +import type { AppDispatch } from 'src/app/store' import { SagaGenerator, select } from 'typed-redux-saga' import { spacing } from 'ui/src/theme' // Use throughout the app instead of plain `useDispatch` and `useSelector` - +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const useAppDispatch = (): ThunkDispatch => useDispatch() export const useAppSelector: TypedUseSelectorHook = useSelector // Use in sagas for better typing when selecting from redux state diff --git a/apps/mobile/src/app/migrations.test.ts b/apps/mobile/src/app/migrations.test.ts index fc3078f59a8..82073bc0c06 100644 --- a/apps/mobile/src/app/migrations.test.ts +++ b/apps/mobile/src/app/migrations.test.ts @@ -81,7 +81,10 @@ import { initialWalletConnectState } from 'src/features/walletConnect/walletConn import { ModalName } from 'uniswap/src/features/telemetry/constants' import { UniverseChainId, WalletChainId } from 'uniswap/src/types/chains' import { ScannerModalState } from 'wallet/src/components/QRCodeScanner/constants' -import { ExtensionOnboardingState, initialBehaviorHistoryState } from 'wallet/src/features/behaviorHistory/slice' +import { + ExtensionOnboardingState, + initialBehaviorHistoryState, +} from 'wallet/src/features/behaviorHistory/slice' import { initialFavoritesState } from 'wallet/src/features/favorites/slice' import { initialFiatCurrencyState } from 'wallet/src/features/fiatCurrency/slice' import { initialLanguageState } from 'wallet/src/features/language/slice' @@ -91,12 +94,20 @@ import { initialTelemetryState } from 'wallet/src/features/telemetry/slice' import { initialTokensState } from 'wallet/src/features/tokens/tokensSlice' import { initialTransactionsState } from 'wallet/src/features/transactions/slice' import { TransactionStatus, TransactionType } from 'wallet/src/features/transactions/types' -import { Account, AccountType, SignerMnemonicAccount } from 'wallet/src/features/wallet/accounts/types' +import { + Account, + AccountType, + SignerMnemonicAccount, +} from 'wallet/src/features/wallet/accounts/types' import { initialWalletState, SwapProtectionSetting } from 'wallet/src/features/wallet/slice' import { createMigrate } from 'wallet/src/state/createMigrate' import { testActivatePendingAccounts } from 'wallet/src/state/sharedMigrationsTests' import { getAllKeysOfNestedObject } from 'wallet/src/state/testUtils' -import { fiatPurchaseTransactionInfo, signerMnemonicAccount, transactionDetails } from 'wallet/src/test/fixtures' +import { + fiatPurchaseTransactionInfo, + signerMnemonicAccount, + transactionDetails, +} from 'wallet/src/test/fixtures' expect.extend({ toIncludeSameMembers }) @@ -278,17 +289,21 @@ describe('Redux state migrations', () => { expect(newSchema.transactions[UniverseChainId.Mainnet]).toBeUndefined() expect(newSchema.transactions.lastTxHistoryUpdate).toBeUndefined() - expect(newSchema.transactions['0xShadowySuperCoder'][UniverseChainId.Mainnet]['0'].status).toEqual( - TransactionStatus.Pending, - ) + expect( + newSchema.transactions['0xShadowySuperCoder'][UniverseChainId.Mainnet]['0'].status + ).toEqual(TransactionStatus.Pending) expect(newSchema.transactions['0xKingHodler'][UniverseChainId.Mainnet]).toBeUndefined() expect(newSchema.transactions['0xKingHodler'][UniverseChainId.Goerli]['0']).toBeUndefined() - expect(newSchema.transactions['0xKingHodler'][UniverseChainId.Goerli]['1'].from).toEqual('0xKingHodler') + expect(newSchema.transactions['0xKingHodler'][UniverseChainId.Goerli]['1'].from).toEqual( + '0xKingHodler' + ) expect(newSchema.notifications.lastTxNotificationUpdate).toBeDefined() - expect(newSchema.notifications.lastTxNotificationUpdate['0xShadowySuperCoder'][UniverseChainId.Mainnet]).toEqual( - 12345678912345, - ) + expect( + newSchema.notifications.lastTxNotificationUpdate['0xShadowySuperCoder'][ + UniverseChainId.Mainnet + ] + ).toEqual(12345678912345) }) it('migrates from v0 to v1', () => { @@ -393,7 +408,12 @@ describe('Redux state migrations', () => { }) it('migrates from v6 to v7', () => { - const TEST_ADDRESSES: [string, string, string, string] = ['0xTest', '0xTest2', '0xTest3', '0xTest4'] + const TEST_ADDRESSES: [string, string, string, string] = [ + '0xTest', + '0xTest2', + '0xTest3', + '0xTest4', + ] const TEST_IMPORT_TIME_MS = 12345678912345 const v6SchemaStub = { @@ -451,7 +471,12 @@ describe('Redux state migrations', () => { }) it('migrates from v8 to v9', () => { - const TEST_ADDRESSES: [string, string, string, string] = ['0xTest', '0xTest2', '0xTest3', '0xTest4'] + const TEST_ADDRESSES: [string, string, string, string] = [ + '0xTest', + '0xTest2', + '0xTest3', + '0xTest4', + ] const TEST_IMPORT_TIME_MS = 12345678912345 const v8SchemaStub = { @@ -487,18 +512,15 @@ describe('Redux state migrations', () => { const TEST_ADDRESSES = ['0xTest', OLD_DEMO_ACCOUNT_ADDRESS, '0xTest2', '0xTest3'] const TEST_IMPORT_TIME_MS = 12345678912345 - const accounts = TEST_ADDRESSES.reduce( - (acc, address) => { - acc[address] = { - address, - timeImportedMs: TEST_IMPORT_TIME_MS, - type: 'native', - } as unknown as Account + const accounts = TEST_ADDRESSES.reduce((acc, address) => { + acc[address] = { + address, + timeImportedMs: TEST_IMPORT_TIME_MS, + type: 'native', + } as unknown as Account - return acc - }, - {} as { [address: string]: Account }, - ) + return acc + }, {} as { [address: string]: Account }) const v9SchemaStub = { ...v9Schema, @@ -976,18 +998,34 @@ describe('Redux state migrations', () => { const v30 = migrations[30](v29Stub) // expect fiat onramp txdetails to change - expect(v30.transactions[account.address][UniverseChainId.Mainnet]['0'].typeInfo).toEqual(expectedTypeInfo) + expect(v30.transactions[account.address][UniverseChainId.Mainnet]['0'].typeInfo).toEqual( + expectedTypeInfo + ) expect(v30.transactions[account.address][UniverseChainId.Goerli]['0']).toBeUndefined() expect(v30.transactions[account.address][UniverseChainId.ArbitrumOne]).toBeUndefined() // does not create an object for chain - expect(v30.transactions['0xshadowySuperCoder'][UniverseChainId.ArbitrumOne]['0'].typeInfo).toEqual(expectedTypeInfo) - expect(v30.transactions['0xshadowySuperCoder'][UniverseChainId.Optimism]['0'].typeInfo).toEqual(expectedTypeInfo) - expect(v30.transactions['0xshadowySuperCoder'][UniverseChainId.Optimism]['1'].typeInfo).toEqual(expectedTypeInfo) + expect( + v30.transactions['0xshadowySuperCoder'][UniverseChainId.ArbitrumOne]['0'].typeInfo + ).toEqual(expectedTypeInfo) + expect(v30.transactions['0xshadowySuperCoder'][UniverseChainId.Optimism]['0'].typeInfo).toEqual( + expectedTypeInfo + ) + expect(v30.transactions['0xshadowySuperCoder'][UniverseChainId.Optimism]['1'].typeInfo).toEqual( + expectedTypeInfo + ) expect(v30.transactions['0xdeleteMe']).toBe(undefined) // expect non-for txDetails to not change - expect(v30.transactions[account.address][UniverseChainId.Mainnet]['1']).toEqual(txDetailsConfirmed) - expect(v30.transactions[account.address][UniverseChainId.Goerli]['1']).toEqual(txDetailsConfirmed) - expect(v30.transactions['0xshadowySuperCoder'][UniverseChainId.ArbitrumOne]['1']).toEqual(txDetailsConfirmed) - expect(v30.transactions['0xshadowySuperCoder'][UniverseChainId.Optimism]['2']).toEqual(txDetailsConfirmed) + expect(v30.transactions[account.address][UniverseChainId.Mainnet]['1']).toEqual( + txDetailsConfirmed + ) + expect(v30.transactions[account.address][UniverseChainId.Goerli]['1']).toEqual( + txDetailsConfirmed + ) + expect(v30.transactions['0xshadowySuperCoder'][UniverseChainId.ArbitrumOne]['1']).toEqual( + txDetailsConfirmed + ) + expect(v30.transactions['0xshadowySuperCoder'][UniverseChainId.Optimism]['2']).toEqual( + txDetailsConfirmed + ) }) it('migrates from v31 to 32', () => { @@ -1060,16 +1098,24 @@ describe('Redux state migrations', () => { const v36Stub = { ...v36Schema, transactions } - expect(v36Stub.transactions[account.address]?.[UniverseChainId.Mainnet][id1].typeInfo.id).toBeUndefined() - expect(v36Stub.transactions[account.address]?.[UniverseChainId.Mainnet][id2].typeInfo.id).toBeUndefined() + expect( + v36Stub.transactions[account.address]?.[UniverseChainId.Mainnet][id1].typeInfo.id + ).toBeUndefined() + expect( + v36Stub.transactions[account.address]?.[UniverseChainId.Mainnet][id2].typeInfo.id + ).toBeUndefined() const v37 = migrations[37](v36Stub) expect(v37.transactions[account.address]?.[UniverseChainId.Mainnet][id1].typeInfo.id).toEqual( - fiatOnRampTxDetailsFailed.typeInfo.id, + fiatOnRampTxDetailsFailed.typeInfo.id + ) + expect( + v36Stub.transactions[account.address]?.[UniverseChainId.Mainnet][id2].typeInfo.id + ).toBeUndefined() + expect(v36Stub.transactions[account.address]?.[UniverseChainId.Mainnet][id3]).toEqual( + txDetailsConfirmed ) - expect(v36Stub.transactions[account.address]?.[UniverseChainId.Mainnet][id2].typeInfo.id).toBeUndefined() - expect(v36Stub.transactions[account.address]?.[UniverseChainId.Mainnet][id3]).toEqual(txDetailsConfirmed) }) it('migrates from v37 to 38', () => { diff --git a/apps/mobile/src/app/modals/AccountSwitcherModal.tsx b/apps/mobile/src/app/modals/AccountSwitcherModal.tsx index deedeec4b0c..6e16db031bc 100644 --- a/apps/mobile/src/app/modals/AccountSwitcherModal.tsx +++ b/apps/mobile/src/app/modals/AccountSwitcherModal.tsx @@ -1,9 +1,8 @@ import React, { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { Alert } from 'react-native' -import { useDispatch } from 'react-redux' import { Action } from 'redux' -import { useAppSelector } from 'src/app/hooks' +import { useAppDispatch, useAppSelector } from 'src/app/hooks' import { navigate } from 'src/app/navigation/rootNavigation' import { AccountList } from 'src/components/accounts/AccountList' import { isCloudStorageAvailable } from 'src/features/CloudBackup/RNCloudStorageBackupsManager' @@ -31,7 +30,7 @@ import { setAccountAsActive } from 'wallet/src/features/wallet/slice' import { openSettings } from 'wallet/src/utils/linking' export function AccountSwitcherModal(): JSX.Element { - const dispatch = useDispatch() + const dispatch = useAppDispatch() const colors = useSporeColors() return ( @@ -60,7 +59,7 @@ export function AccountSwitcher({ onClose }: { onClose: () => void }): JSX.Eleme const dimensions = useDeviceDimensions() const { t } = useTranslation() const activeAccountAddress = useActiveAccountAddress() - const dispatch = useDispatch() + const dispatch = useAppDispatch() const hasImportedSeedPhrase = useNativeAccountExists() const modalState = useAppSelector(selectModalState(ModalName.AccountSwitcher)) const sortedMnemonicAccounts = useAppSelector(selectSortedSignerMnemonicAccounts) diff --git a/apps/mobile/src/app/modals/AppModals.tsx b/apps/mobile/src/app/modals/AppModals.tsx index 005668f9205..450c5ae231a 100644 --- a/apps/mobile/src/app/modals/AppModals.tsx +++ b/apps/mobile/src/app/modals/AppModals.tsx @@ -1,5 +1,4 @@ import React, { useCallback } from 'react' -import { useDispatch } from 'react-redux' import { AccountSwitcherModal } from 'src/app/modals/AccountSwitcherModal' import { ExperimentsModal } from 'src/app/modals/ExperimentsModal' import { ExploreModal } from 'src/app/modals/ExploreModal' @@ -8,13 +7,14 @@ import { TransferTokenModal } from 'src/app/modals/TransferTokenModal' import { ViewOnlyExplainerModal } from 'src/app/modals/ViewOnlyExplainerModal' import { LazyModalRenderer } from 'src/app/modals/utils' import { RemoveWalletModal } from 'src/components/RemoveWallet/RemoveWalletModal' -import { WalletConnectModals } from 'src/components/Requests/WalletConnectModals' import { RestoreWalletModal } from 'src/components/RestoreWalletModal/RestoreWalletModal' +import { WalletConnectModals } from 'src/components/WalletConnect/WalletConnectModals' import { ForceUpgradeModal } from 'src/components/forceUpgrade/ForceUpgradeModal' import { UnitagsIntroModal } from 'src/components/unitags/UnitagsIntroModal' import { LockScreenModal } from 'src/features/authentication/LockScreenModal' import { ExchangeTransferModal } from 'src/features/fiatOnRamp/ExchangeTransferModal' import { FiatOnRampAggregatorModal } from 'src/features/fiatOnRamp/FiatOnRampAggregatorModal' +import { FiatOnRampModal } from 'src/features/fiatOnRamp/FiatOnRampModal' import { closeModal } from 'src/features/modals/modalSlice' import { ScantasticModal } from 'src/features/scantastic/ScantasticModal' import { ReceiveCryptoModal } from 'src/screens/ReceiveCryptoModal' @@ -22,9 +22,10 @@ import { SettingsFiatCurrencyModal } from 'src/screens/SettingsFiatCurrencyModal import { ModalName } from 'uniswap/src/features/telemetry/constants' import { SettingsLanguageModal } from 'wallet/src/components/settings/language/SettingsLanguageModal' import { QueuedOrderModal } from 'wallet/src/features/transactions/swap/modals/QueuedOrderModal' +import { useAppDispatch } from 'wallet/src/state' export function AppModals(): JSX.Element { - const dispatch = useDispatch() + const dispatch = useAppDispatch() const onCloseLanguageModal = useCallback(() => { dispatch(closeModal({ name: ModalName.LanguageSelector })) @@ -40,6 +41,10 @@ export function AppModals(): JSX.Element { + + + + diff --git a/apps/mobile/src/app/modals/ExperimentsModal.tsx b/apps/mobile/src/app/modals/ExperimentsModal.tsx index 55d42893e1a..61107c204a3 100644 --- a/apps/mobile/src/app/modals/ExperimentsModal.tsx +++ b/apps/mobile/src/app/modals/ExperimentsModal.tsx @@ -1,9 +1,8 @@ import { useApolloClient } from '@apollo/client' import React, { useState } from 'react' import { ScrollView } from 'react-native-gesture-handler' -import { useDispatch } from 'react-redux' import { Action } from 'redux' -import { useAppSelector } from 'src/app/hooks' +import { useAppDispatch, useAppSelector } from 'src/app/hooks' import { closeModal } from 'src/features/modals/modalSlice' import { selectCustomEndpoint } from 'src/features/tweaks/selectors' import { setCustomEndpoint } from 'src/features/tweaks/slice' @@ -16,7 +15,7 @@ import { AccordionHeader, GatingOverrides } from 'wallet/src/components/gating/G export function ExperimentsModal(): JSX.Element { const insets = useDeviceInsets() - const dispatch = useDispatch() + const dispatch = useAppDispatch() const customEndpoint = useAppSelector(selectCustomEndpoint) const apollo = useApolloClient() diff --git a/apps/mobile/src/app/modals/ExploreModal.tsx b/apps/mobile/src/app/modals/ExploreModal.tsx index fcd7fda8845..8b5229d0903 100644 --- a/apps/mobile/src/app/modals/ExploreModal.tsx +++ b/apps/mobile/src/app/modals/ExploreModal.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { useDispatch } from 'react-redux' +import { useAppDispatch } from 'src/app/hooks' import { ExploreStackNavigator } from 'src/app/navigation/navigation' import { closeModal } from 'src/features/modals/modalSlice' import { useSporeColors } from 'ui/src' @@ -8,7 +8,7 @@ import { ModalName } from 'uniswap/src/features/telemetry/constants' export function ExploreModal(): JSX.Element { const colors = useSporeColors() - const appDispatch = useDispatch() + const appDispatch = useAppDispatch() const onClose = (): void => { appDispatch(closeModal({ name: ModalName.Explore })) diff --git a/apps/mobile/src/app/modals/SwapModal.tsx b/apps/mobile/src/app/modals/SwapModal.tsx index a54f83c9486..581bf3fba20 100644 --- a/apps/mobile/src/app/modals/SwapModal.tsx +++ b/apps/mobile/src/app/modals/SwapModal.tsx @@ -1,6 +1,5 @@ import React, { useCallback, useEffect } from 'react' -import { useDispatch } from 'react-redux' -import { useAppSelector } from 'src/app/hooks' +import { useAppDispatch, useAppSelector } from 'src/app/hooks' import { BiometricsIcon } from 'src/components/icons/BiometricsIcon' import { useBiometricAppSettings, useBiometricPrompt, useOsBiometricAuthEnabled } from 'src/features/biometrics/hooks' import { closeModal } from 'src/features/modals/modalSlice' @@ -12,7 +11,7 @@ import { SwapFlow } from 'wallet/src/features/transactions/swap/SwapFlow' import { useSwapPrefilledState } from 'wallet/src/features/transactions/swap/hooks/useSwapPrefilledState' export function SwapModal(): JSX.Element { - const appDispatch = useDispatch() + const appDispatch = useAppDispatch() const { initialState } = useAppSelector(selectModalState(ModalName.Swap)) const onClose = useCallback((): void => { diff --git a/apps/mobile/src/app/modals/TransferTokenModal.tsx b/apps/mobile/src/app/modals/TransferTokenModal.tsx index 95bc22c9265..a98009dd521 100644 --- a/apps/mobile/src/app/modals/TransferTokenModal.tsx +++ b/apps/mobile/src/app/modals/TransferTokenModal.tsx @@ -1,6 +1,5 @@ import React, { useCallback } from 'react' -import { useDispatch } from 'react-redux' -import { useAppSelector } from 'src/app/hooks' +import { useAppDispatch, useAppSelector } from 'src/app/hooks' import { closeModal } from 'src/features/modals/modalSlice' import { selectModalState } from 'src/features/modals/selectModalState' import { TransferFlow } from 'src/features/transactions/transfer/TransferFlow' @@ -13,7 +12,7 @@ import { ModalName } from 'uniswap/src/features/telemetry/constants' export function TransferTokenModal(): JSX.Element { const colors = useSporeColors() - const appDispatch = useDispatch() + const appDispatch = useAppDispatch() const modalState = useAppSelector(selectModalState(ModalName.Send)) const onClose = useCallback((): void => { diff --git a/apps/mobile/src/app/modals/ViewOnlyExplainerModal.tsx b/apps/mobile/src/app/modals/ViewOnlyExplainerModal.tsx index 5266bad6bd3..0acabb272ce 100644 --- a/apps/mobile/src/app/modals/ViewOnlyExplainerModal.tsx +++ b/apps/mobile/src/app/modals/ViewOnlyExplainerModal.tsx @@ -1,5 +1,4 @@ import { useTranslation } from 'react-i18next' -import { useDispatch } from 'react-redux' import { navigate } from 'src/app/navigation/rootNavigation' import { closeModal, openModal } from 'src/features/modals/modalSlice' import { Button, Flex, Text, useIsDarkMode } from 'ui/src' @@ -10,13 +9,14 @@ import { ModalName } from 'uniswap/src/features/telemetry/constants' import { ImportType, OnboardingEntryPoint } from 'uniswap/src/types/onboarding' import { MobileScreens, OnboardingScreens } from 'uniswap/src/types/screens/mobile' import { useActiveAccountAddress, useNativeAccountExists } from 'wallet/src/features/wallet/hooks' +import { useAppDispatch } from 'wallet/src/state' const WALLET_IMAGE_ASPECT_RATIO = 327 / 215 export function ViewOnlyExplainerModal(): JSX.Element { const { t } = useTranslation() const activeAccountAddress = useActiveAccountAddress() - const dispatch = useDispatch() + const dispatch = useAppDispatch() const hasImportedSeedPhrase = useNativeAccountExists() const isDarkMode = useIsDarkMode() diff --git a/apps/mobile/src/app/navigation/NavBar.tsx b/apps/mobile/src/app/navigation/NavBar.tsx index ca600672974..a6ddcc0bc3d 100644 --- a/apps/mobile/src/app/navigation/NavBar.tsx +++ b/apps/mobile/src/app/navigation/NavBar.tsx @@ -11,7 +11,7 @@ import { useAnimatedStyle, useSharedValue, } from 'react-native-reanimated' -import { useDispatch } from 'react-redux' +import { useAppDispatch } from 'src/app/hooks' import { pulseAnimation } from 'src/components/buttons/utils' import { openModal } from 'src/features/modals/modalSlice' import { @@ -32,11 +32,11 @@ import { ElementName, ModalName } 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 { opacify } from 'uniswap/src/utils/colors' import { isAndroid, isIOS } from 'utilities/src/platform' import { useHighestBalanceNativeCurrencyId } from 'wallet/src/features/dataApi/balances' import { prepareSwapFormState } from 'wallet/src/features/transactions/swap/utils' import { useActiveAccountAddressWithThrow } from 'wallet/src/features/wallet/hooks' +import { opacify } from 'wallet/src/utils/colors' export const NAV_BAR_HEIGHT_XS = 52 export const NAV_BAR_HEIGHT_SM = 72 @@ -106,7 +106,7 @@ type SwapTabBarButtonProps = { const SwapFAB = memo(function _SwapFAB({ activeScale = 0.96 }: SwapTabBarButtonProps) { const { t } = useTranslation() - const dispatch = useDispatch() + const dispatch = useAppDispatch() const isDarkMode = useIsDarkMode() @@ -182,7 +182,7 @@ type ExploreTabBarButtonProps = { } function ExploreTabBarButton({ activeScale = 0.98 }: ExploreTabBarButtonProps): JSX.Element { - const dispatch = useDispatch() + const dispatch = useAppDispatch() const colors = useSporeColors() const isDarkMode = useIsDarkMode() const { t } = useTranslation() diff --git a/apps/mobile/src/app/navigation/NavigationContainer.tsx b/apps/mobile/src/app/navigation/NavigationContainer.tsx index f2bbb93034b..1d46a5feb89 100644 --- a/apps/mobile/src/app/navigation/NavigationContainer.tsx +++ b/apps/mobile/src/app/navigation/NavigationContainer.tsx @@ -7,7 +7,7 @@ import { import { SharedEventName } from '@uniswap/analytics-events' import React, { FC, PropsWithChildren, useCallback, useState } from 'react' import { Linking } from 'react-native' -import { useDispatch } from 'react-redux' +import { useAppDispatch } from 'src/app/hooks' import { RootParamList } from 'src/app/navigation/types' import { openDeepLink } from 'src/features/deepLinking/handleDeepLinkSaga' import { DIRECT_LOG_ONLY_SCREENS } from 'src/features/telemetry/directLogScreens' @@ -82,7 +82,7 @@ export const NavigationContainer: FC> = ({ children, on } export const useManageDeepLinks = (): void => { - const dispatch = useDispatch() + const dispatch = useAppDispatch() const manageDeepLinks = useCallback(async () => { const url = await Linking.getInitialURL() if (url) { diff --git a/apps/mobile/src/app/navigation/components.tsx b/apps/mobile/src/app/navigation/components.tsx deleted file mode 100644 index ea31bfb4a44..00000000000 --- a/apps/mobile/src/app/navigation/components.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { BackButton } from 'src/components/buttons/BackButton' -import { RotatableChevron } from 'ui/src/components/icons' -import { iconSizes } from 'ui/src/theme' - -export const renderHeaderBackButton = (): JSX.Element => - -export const renderHeaderBackImage = (): JSX.Element => ( - -) diff --git a/apps/mobile/src/app/navigation/navigation.tsx b/apps/mobile/src/app/navigation/navigation.tsx index cbb449b805b..460483d4ff2 100644 --- a/apps/mobile/src/app/navigation/navigation.tsx +++ b/apps/mobile/src/app/navigation/navigation.tsx @@ -3,7 +3,6 @@ import { createNativeStackNavigator } from '@react-navigation/native-stack' import { createStackNavigator, TransitionPresets } from '@react-navigation/stack' import React from 'react' import { useAppSelector } from 'src/app/hooks' -import { renderHeaderBackButton, renderHeaderBackImage } from 'src/app/navigation/components' import { AppStackParamList, AppStackScreenProp, @@ -13,6 +12,7 @@ import { SettingsStackParamList, UnitagStackParamList, } from 'src/app/navigation/types' +import { BackButton } from 'src/components/buttons/BackButton' import { HorizontalEdgeGestureTarget } from 'src/components/layout/screens/EdgeGestureTarget' import { useBiometricCheck } from 'src/features/biometrics/useBiometricCheck' import { FiatOnRampProvider } from 'src/features/fiatOnRamp/FiatOnRampContext' @@ -65,6 +65,7 @@ import { SettingsWalletManageConnection } from 'src/screens/SettingsWalletManage import { TokenDetailsScreen } from 'src/screens/TokenDetailsScreen' import { WebViewScreen } from 'src/screens/WebViewScreen' import { useDeviceInsets, useSporeColors } from 'ui/src' +import { RotatableChevron } from 'ui/src/components/icons' import { spacing } from 'ui/src/theme' import { FeatureFlags } from 'uniswap/src/features/gating/flags' import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' @@ -200,6 +201,8 @@ export function FiatOnRampStackNavigator(): JSX.Element { ) } +const renderHeaderBackButton = (): JSX.Element => + export function OnboardingStackNavigator(): JSX.Element { const colors = useSporeColors() const seedPhraseRefactorEnabled = useFeatureFlag(FeatureFlags.SeedPhraseRefactorNative) @@ -213,8 +216,7 @@ export function OnboardingStackNavigator(): JSX.Element { + export function UnitagStackNavigator(): JSX.Element { const colors = useSporeColors() const insets = useDeviceInsets() diff --git a/apps/mobile/src/app/store.ts b/apps/mobile/src/app/store.ts index 9d0cc056850..7305e6bef0e 100644 --- a/apps/mobile/src/app/store.ts +++ b/apps/mobile/src/app/store.ts @@ -6,9 +6,10 @@ import { Storage, persistReducer, persistStore } from 'redux-persist' import { MOBILE_STATE_VERSION, migrations } from 'src/app/migrations' import { MobileState, ReducerNames, mobileReducer } from 'src/app/reducer' import { mobileSaga } from 'src/app/saga' -import { fiatOnRampAggregatorApi } from 'uniswap/src/features/fiatOnRamp/api' +import { fiatOnRampAggregatorApi as sharedFiatOnRampAggregatorApi } from 'uniswap/src/features/fiatOnRamp/api' import { isNonJestDev } from 'utilities/src/environment/constants' import { logger } from 'utilities/src/logger/logger' +import { fiatOnRampAggregatorApi, fiatOnRampApi } from 'wallet/src/features/fiatOnRamp/api' import { createStore } from 'wallet/src/state' import { createMigrate } from 'wallet/src/state/createMigrate' import { RootReducerNames, sharedPersistedStateWhitelist } from 'wallet/src/state/reducer' @@ -81,7 +82,11 @@ const sentryReduxEnhancer = Sentry.createReduxEnhancer({ }, }) -const middlewares: Middleware[] = [fiatOnRampAggregatorApi.middleware] +const middlewares: Middleware[] = [ + fiatOnRampApi.middleware, + fiatOnRampAggregatorApi.middleware, + sharedFiatOnRampAggregatorApi.middleware, +] if (isNonJestDev) { const createDebugger = require('redux-flipper').default middlewares.push(createDebugger()) @@ -102,3 +107,6 @@ export const setupStore = ( export const store = setupStore() export const persistor = persistStore(store) + +export type AppDispatch = typeof store.dispatch +export type AppStore = typeof store diff --git a/apps/mobile/src/components/NFT/NftView.tsx b/apps/mobile/src/components/NFT/NftView.tsx index 2884773ec21..d7996c2a047 100644 --- a/apps/mobile/src/components/NFT/NftView.tsx +++ b/apps/mobile/src/components/NFT/NftView.tsx @@ -7,17 +7,7 @@ import { ESTIMATED_NFT_LIST_ITEM_SIZE, MAX_NFT_IMAGE_SIZE } from 'wallet/src/fea import { NFTItem } from 'wallet/src/features/nfts/types' import { useNFTContextMenu } from 'wallet/src/features/nfts/useNftContextMenu' -export function NftView({ - owner, - item, - onPress, - index, -}: { - owner: Address - item: NFTItem - index?: number - onPress: () => void -}): JSX.Element { +export function NftView({ owner, item, onPress }: { owner: Address; item: NFTItem; onPress: () => void }): JSX.Element { const { menuActions, onContextMenuPress } = useNFTContextMenu({ contractAddress: item.contractAddress, tokenId: item.tokenId, @@ -37,7 +27,6 @@ export function NftView({ hapticFeedback activeOpacity={1} hapticStyle={ImpactFeedbackStyle.Light} - testID={`nfts-list-item-${index ?? 0}`} // Needed to fix long press issue with context menu on Android onLongPress={noop} onPress={onPress} diff --git a/apps/mobile/src/components/PriceExplorer/useChartDimensions.test.ts b/apps/mobile/src/components/PriceExplorer/useChartDimensions.test.ts index 91cb356dedd..463fca48452 100644 --- a/apps/mobile/src/components/PriceExplorer/useChartDimensions.test.ts +++ b/apps/mobile/src/components/PriceExplorer/useChartDimensions.test.ts @@ -12,7 +12,9 @@ const sharedDimensions = { describe(useChartDimensions, () => { it('returns small chart height for small screens', () => { - jest.spyOn(Dimensions, 'get').mockReturnValue({ ...sharedDimensions, height: heightBreakpoints.short - 1 }) + jest + .spyOn(Dimensions, 'get') + .mockReturnValue({ ...sharedDimensions, height: heightBreakpoints.short - 1 }) const { result } = renderHook(() => useChartDimensions()) expect(result.current).toEqual({ @@ -24,7 +26,9 @@ describe(useChartDimensions, () => { }) it('returns large chart height for large screens', () => { - jest.spyOn(Dimensions, 'get').mockReturnValue({ ...sharedDimensions, height: heightBreakpoints.short }) + jest + .spyOn(Dimensions, 'get') + .mockReturnValue({ ...sharedDimensions, height: heightBreakpoints.short }) const { result } = renderHook(() => useChartDimensions()) expect(result.current).toEqual({ diff --git a/apps/mobile/src/components/PriceExplorer/usePrice.test.ts b/apps/mobile/src/components/PriceExplorer/usePrice.test.ts index 563ea30d6fc..83ed0a3307f 100644 --- a/apps/mobile/src/components/PriceExplorer/usePrice.test.ts +++ b/apps/mobile/src/components/PriceExplorer/usePrice.test.ts @@ -6,7 +6,10 @@ import { useLineChartPrice as useRNWagmiChartLineChartPrice, } from 'react-native-wagmi-charts' import { act } from 'react-test-renderer' -import { useLineChartPrice, useLineChartRelativeChange } from 'src/components/PriceExplorer/usePrice' +import { + useLineChartPrice, + useLineChartRelativeChange, +} from 'src/components/PriceExplorer/usePrice' import { renderHookWithProviders } from 'src/test/render' jest.mock('react-native-wagmi-charts') @@ -17,7 +20,9 @@ const cursorFormattedValue = makeMutable('-') const currentIndex = makeMutable(0) const isActive = makeMutable(false) -const mockData = (args: { data?: TLineChartData; currentIndex?: number; isActive?: boolean } = {}): void => { +const mockData = ( + args: { data?: TLineChartData; currentIndex?: number; isActive?: boolean } = {} +): void => { currentIndex.value = args.currentIndex ?? 0 isActive.value = args.isActive ?? false // react-native-wagmi-charts is mocked so we can mock the return @@ -47,7 +52,9 @@ describe(useLineChartPrice, () => { beforeEach(() => { const originalModule = jest.requireActual('react-native-wagmi-charts') ;(useLineChart as ReturnType).mockImplementation(originalModule.useLineChart) - ;(useRNWagmiChartLineChartPrice as ReturnType).mockImplementation(originalModule.useLineChartPrice) + ;(useRNWagmiChartLineChartPrice as ReturnType).mockImplementation( + originalModule.useLineChartPrice + ) }) afterAll(() => { @@ -165,7 +172,7 @@ describe(useLineChartPrice, () => { expect.objectContaining({ value: expect.objectContaining({ value: 1 }), formatted: expect.objectContaining({ value: '$1.00' }), - }), + }) ) mockCursorPrice('2') // updates shared values @@ -175,7 +182,7 @@ describe(useLineChartPrice, () => { expect.objectContaining({ value: expect.objectContaining({ value: 2 }), formatted: expect.objectContaining({ value: '$2.00' }), - }), + }) ) }) }) @@ -192,7 +199,7 @@ describe(useLineChartPrice, () => { expect.objectContaining({ value: expect.objectContaining({ value: 1 }), shouldAnimate: expect.objectContaining({ value: true }), - }), + }) ) }) @@ -205,7 +212,7 @@ describe(useLineChartPrice, () => { expect.objectContaining({ value: expect.objectContaining({ value: 2 }), shouldAnimate: expect.objectContaining({ value: false }), - }), + }) ) }) }) @@ -260,7 +267,7 @@ describe(useLineChartRelativeChange, () => { expect.objectContaining({ value: expect.objectContaining({ value: 400 }), formatted: expect.objectContaining({ value: '400.00%' }), - }), + }) ) currentIndex.value = 2 @@ -272,7 +279,7 @@ describe(useLineChartRelativeChange, () => { expect.objectContaining({ value: expect.objectContaining({ value: 900 }), formatted: expect.objectContaining({ value: '900.00%' }), - }), + }) ) }) }) diff --git a/apps/mobile/src/components/PriceExplorer/usePriceHistory.test.ts b/apps/mobile/src/components/PriceExplorer/usePriceHistory.test.ts index 3d9347d4c95..173d629de2e 100644 --- a/apps/mobile/src/components/PriceExplorer/usePriceHistory.test.ts +++ b/apps/mobile/src/components/PriceExplorer/usePriceHistory.test.ts @@ -161,7 +161,12 @@ describe(useTokenPriceHistory, () => { const { resolvers } = queryResolvers({ tokenProjects: () => [ usdcTokenProject({ - priceHistory: [undefined, timestampedAmount({ value: 1 }), undefined, timestampedAmount({ value: 2 })], + priceHistory: [ + undefined, + timestampedAmount({ value: 1 }), + undefined, + timestampedAmount({ value: 2 }), + ], }), ], }) @@ -217,7 +222,10 @@ describe(useTokenPriceHistory, () => { describe('when duration is set to default value (day)', () => { it('returns correct price history', async () => { - const { result } = renderHookWithProviders(() => useTokenPriceHistory(SAMPLE_CURRENCY_ID_1), { resolvers }) + const { result } = renderHookWithProviders( + () => useTokenPriceHistory(SAMPLE_CURRENCY_ID_1), + { resolvers } + ) await waitFor(() => { expect(result.current).toEqual( @@ -227,13 +235,16 @@ describe(useTokenPriceHistory, () => { spot: expect.anything(), }, selectedDuration: HistoryDuration.Day, - }), + }) ) }) }) it('returns correct spot price', async () => { - const { result } = renderHookWithProviders(() => useTokenPriceHistory(SAMPLE_CURRENCY_ID_1), { resolvers }) + const { result } = renderHookWithProviders( + () => useTokenPriceHistory(SAMPLE_CURRENCY_ID_1), + { resolvers } + ) await waitFor(() => { expect(result.current.data?.spot).toEqual({ @@ -250,7 +261,7 @@ describe(useTokenPriceHistory, () => { it('returns correct price history', async () => { const { result } = renderHookWithProviders( () => useTokenPriceHistory(SAMPLE_CURRENCY_ID_1, jest.fn(), HistoryDuration.Year), - { resolvers }, + { resolvers } ) await waitFor(() => { @@ -261,7 +272,7 @@ describe(useTokenPriceHistory, () => { spot: expect.anything(), }, selectedDuration: HistoryDuration.Year, - }), + }) ) }) }) @@ -269,7 +280,7 @@ describe(useTokenPriceHistory, () => { it('returns correct spot price', async () => { const { result } = renderHookWithProviders( () => useTokenPriceHistory(SAMPLE_CURRENCY_ID_1, jest.fn(), HistoryDuration.Year), - { resolvers }, + { resolvers } ) await waitFor(() => { expect(result.current.data?.spot).toEqual({ @@ -285,9 +296,10 @@ describe(useTokenPriceHistory, () => { describe('when duration is changed', () => { it('re-fetches data', async () => { const onCompleted = jest.fn() - const { result } = renderHookWithProviders(() => useTokenPriceHistory(SAMPLE_CURRENCY_ID_1, onCompleted), { - resolvers, - }) + const { result } = renderHookWithProviders( + () => useTokenPriceHistory(SAMPLE_CURRENCY_ID_1, onCompleted), + { resolvers } + ) await waitFor(() => { expect(result.current).toEqual( @@ -295,7 +307,7 @@ describe(useTokenPriceHistory, () => { loading: false, error: false, selectedDuration: HistoryDuration.Day, - }), + }) ) }) @@ -312,7 +324,7 @@ describe(useTokenPriceHistory, () => { loading: false, error: false, selectedDuration: HistoryDuration.Week, - }), + }) ) }) @@ -320,7 +332,10 @@ describe(useTokenPriceHistory, () => { }) it('returns new price history and spot price', async () => { - const { result } = renderHookWithProviders(() => useTokenPriceHistory(SAMPLE_CURRENCY_ID_1), { resolvers }) + const { result } = renderHookWithProviders( + () => useTokenPriceHistory(SAMPLE_CURRENCY_ID_1), + { resolvers } + ) await waitFor(() => { expect(result.current.data).toEqual({ @@ -361,9 +376,10 @@ describe(useTokenPriceHistory, () => { throw new Error('error') }, }) - const { result } = renderHookWithProviders(() => useTokenPriceHistory(SAMPLE_CURRENCY_ID_1), { - resolvers: errorResolvers, - }) + const { result } = renderHookWithProviders( + () => useTokenPriceHistory(SAMPLE_CURRENCY_ID_1), + { resolvers: errorResolvers } + ) await waitFor(() => { expect(result.current.loading).toBe(false) diff --git a/apps/mobile/src/components/PriceExplorer/usePriceHistory.ts b/apps/mobile/src/components/PriceExplorer/usePriceHistory.ts index 601a89000de..94e7474609a 100644 --- a/apps/mobile/src/components/PriceExplorer/usePriceHistory.ts +++ b/apps/mobile/src/components/PriceExplorer/usePriceHistory.ts @@ -9,8 +9,8 @@ import { useTokenPriceHistoryQuery, } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' import { GqlResult } from 'uniswap/src/data/types' -import { currencyIdToContractInput } from 'uniswap/src/features/dataApi/utils' import { isError, isNonPollingRequestInFlight } from 'wallet/src/data/utils' +import { currencyIdToContractInput } from 'wallet/src/features/dataApi/utils' import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' export type TokenSpotData = { diff --git a/apps/mobile/src/components/QRCodeScanner/QRCodeScanner.tsx b/apps/mobile/src/components/QRCodeScanner/QRCodeScanner.tsx index b132c5c7939..b548fd02e67 100644 --- a/apps/mobile/src/components/QRCodeScanner/QRCodeScanner.tsx +++ b/apps/mobile/src/components/QRCodeScanner/QRCodeScanner.tsx @@ -13,9 +13,9 @@ import { Global, Photo } from 'ui/src/components/icons' import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' import { useDeviceDimensions } from 'ui/src/hooks/useDeviceDimensions' import { iconSizes, spacing } from 'ui/src/theme' -import PasteButton from 'uniswap/src/components/buttons/PasteButton' import { Sentry } from 'utilities/src/logger/Sentry' import { DevelopmentOnly } from 'wallet/src/components/DevelopmentOnly/DevelopmentOnly' +import PasteButton from 'wallet/src/components/buttons/PasteButton' import { openSettings } from 'wallet/src/utils/linking' type QRCodeScannerProps = { diff --git a/apps/mobile/src/components/RecipientSelect/RecipientScanModal.tsx b/apps/mobile/src/components/RecipientSelect/RecipientScanModal.tsx index ad42e86c55f..9607054f176 100644 --- a/apps/mobile/src/components/RecipientSelect/RecipientScanModal.tsx +++ b/apps/mobile/src/components/RecipientSelect/RecipientScanModal.tsx @@ -4,7 +4,7 @@ import { Alert } from 'react-native' import 'react-native-reanimated' import { useAppSelector } from 'src/app/hooks' import { QRCodeScanner } from 'src/components/QRCodeScanner/QRCodeScanner' -import { getSupportedURI, URIType } from 'src/components/Requests/ScanSheet/util' +import { getSupportedURI, URIType } from 'src/components/WalletConnect/ScanSheet/util' import { Flex, HapticFeedback, Text, TouchableArea, useIsDarkMode, useSporeColors } from 'ui/src' import Scan from 'ui/src/assets/icons/receive.svg' import ScanQRIcon from 'ui/src/assets/icons/scan.svg' diff --git a/apps/mobile/src/components/RecipientSelect/RecipientSelect.tsx b/apps/mobile/src/components/RecipientSelect/RecipientSelect.tsx index eb1c26b97cc..14b6a26a772 100644 --- a/apps/mobile/src/components/RecipientSelect/RecipientSelect.tsx +++ b/apps/mobile/src/components/RecipientSelect/RecipientSelect.tsx @@ -75,7 +75,6 @@ export function _RecipientSelect({ value={pattern ?? ''} onBack={recipient ? onHideRecipientSelector : undefined} onChangeText={setPattern} - onDismiss={() => Keyboard.dismiss()} /> {!sections.length ? ( diff --git a/apps/mobile/src/components/RecipientSelect/hooks.test.ts b/apps/mobile/src/components/RecipientSelect/hooks.test.ts index 0157de8eeb3..6221a1b8e61 100644 --- a/apps/mobile/src/components/RecipientSelect/hooks.test.ts +++ b/apps/mobile/src/components/RecipientSelect/hooks.test.ts @@ -140,8 +140,10 @@ describe(useRecipients, () => { expect(result.current).toEqual( expect.objectContaining({ - sections: expect.not.arrayContaining([expect.objectContaining({ title: 'Search results' })]), - }), + sections: expect.not.arrayContaining([ + expect.objectContaining({ title: 'Search results' }), + ]), + }) ) }) @@ -168,7 +170,7 @@ describe(useRecipients, () => { data: expect.objectContaining({ address: SAMPLE_SEED_ADDRESS_1 }), key: SAMPLE_SEED_ADDRESS_1, }, - ]), + ]) ) }) }) @@ -183,7 +185,7 @@ describe(useRecipients, () => { expect(result.current).toEqual( expect.objectContaining({ sections: expect.not.arrayContaining([expect.objectContaining({ title: 'Recent' })]), - }), + }) ) }) @@ -212,7 +214,7 @@ describe(useRecipients, () => { ], }, ]), - }), + }) ) }) @@ -221,9 +223,18 @@ describe(useRecipients, () => { preloadedState: getPreloadedState({ transactions: { [activeAccount.address]: { - [UniverseChainId.Base as WalletChainId]: [sendTxDetailsPending, sendTxDetailsConfirmed], - [UniverseChainId.Mainnet as WalletChainId]: [sendTxDetailsConfirmed, sendTxDetailsFailed], - [UniverseChainId.Bnb as WalletChainId]: [sendTxDetailsPending, sendTxDetailsConfirmed], + [UniverseChainId.Base as WalletChainId]: [ + sendTxDetailsPending, + sendTxDetailsConfirmed, + ], + [UniverseChainId.Mainnet as WalletChainId]: [ + sendTxDetailsConfirmed, + sendTxDetailsFailed, + ], + [UniverseChainId.Bnb as WalletChainId]: [ + sendTxDetailsPending, + sendTxDetailsConfirmed, + ], }, }, }), @@ -254,7 +265,11 @@ describe(useRecipients, () => { preloadedState: getPreloadedState({ transactions: { [activeAccount.address]: { - [sendTxDetailsPending.chainId]: [sendTxDetailsPending, sendTxDetailsFailed, sendTxDetailsConfirmed], + [sendTxDetailsPending.chainId]: [ + sendTxDetailsPending, + sendTxDetailsFailed, + sendTxDetailsConfirmed, + ], }, }, }), @@ -264,7 +279,7 @@ describe(useRecipients, () => { expect(result.current).toEqual( expect.objectContaining({ sections: expect.arrayContaining([recentRecipientsSectionResult]), - }), + }) ) }) @@ -273,9 +288,18 @@ describe(useRecipients, () => { preloadedState: getPreloadedState({ transactions: { [activeAccount.address]: { - [UniverseChainId.Base as WalletChainId]: [sendTxDetailsPending, sendTxDetailsConfirmed], - [UniverseChainId.Mainnet as WalletChainId]: [sendTxDetailsConfirmed, sendTxDetailsFailed], - [UniverseChainId.Bnb as WalletChainId]: [sendTxDetailsPending, sendTxDetailsConfirmed], + [UniverseChainId.Base as WalletChainId]: [ + sendTxDetailsPending, + sendTxDetailsConfirmed, + ], + [UniverseChainId.Mainnet as WalletChainId]: [ + sendTxDetailsConfirmed, + sendTxDetailsFailed, + ], + [UniverseChainId.Bnb as WalletChainId]: [ + sendTxDetailsPending, + sendTxDetailsConfirmed, + ], }, }, }), @@ -295,8 +319,10 @@ describe(useRecipients, () => { expect(result.current).toEqual( expect.objectContaining({ - sections: expect.not.arrayContaining([expect.objectContaining({ title: 'Your wallets' })]), - }), + sections: expect.not.arrayContaining([ + expect.objectContaining({ title: 'Your wallets' }), + ]), + }) ) }) @@ -309,7 +335,7 @@ describe(useRecipients, () => { expect(result.current).toEqual( expect.objectContaining({ sections: expect.arrayContaining([inactiveWalletsSectionResult]), - }), + }) ) }) @@ -322,7 +348,7 @@ describe(useRecipients, () => { expect(result.current).toEqual( expect.objectContaining({ searchableRecipientOptions: [{ data: inactiveAccount, key: inactiveAccount.address }], - }), + }) ) }) }) @@ -336,8 +362,10 @@ describe(useRecipients, () => { expect(result.current).toEqual( expect.objectContaining({ - sections: expect.not.arrayContaining([expect.objectContaining({ title: 'Favorite wallets' })]), - }), + sections: expect.not.arrayContaining([ + expect.objectContaining({ title: 'Favorite wallets' }), + ]), + }) ) }) @@ -352,7 +380,7 @@ describe(useRecipients, () => { expect(result.current).toEqual( expect.objectContaining({ sections: expect.arrayContaining([favoriteWalletsSectionResult]), - }), + }) ) }) }) @@ -365,9 +393,18 @@ describe(useRecipients, () => { hasInactiveAccounts: true, transactions: { [activeAccount.address]: { - [UniverseChainId.Base as WalletChainId]: [sendTxDetailsPending, sendTxDetailsConfirmed], - [UniverseChainId.Mainnet as WalletChainId]: [sendTxDetailsConfirmed, sendTxDetailsFailed], - [UniverseChainId.Bnb as WalletChainId]: [sendTxDetailsPending, sendTxDetailsConfirmed], + [UniverseChainId.Base as WalletChainId]: [ + sendTxDetailsPending, + sendTxDetailsConfirmed, + ], + [UniverseChainId.Mainnet as WalletChainId]: [ + sendTxDetailsConfirmed, + sendTxDetailsFailed, + ], + [UniverseChainId.Bnb as WalletChainId]: [ + sendTxDetailsPending, + sendTxDetailsConfirmed, + ], }, }, }), @@ -383,7 +420,7 @@ describe(useRecipients, () => { inactiveWalletsSectionResult, favoriteWalletsSectionResult, ]), - }), + }) ) }) }) @@ -395,9 +432,18 @@ describe(useRecipients, () => { hasInactiveAccounts: true, transactions: { [activeAccount.address]: { - [UniverseChainId.Base as WalletChainId]: [sendTxDetailsPending, sendTxDetailsConfirmed], - [UniverseChainId.Mainnet as WalletChainId]: [sendTxDetailsConfirmed, sendTxDetailsFailed], - [UniverseChainId.Bnb as WalletChainId]: [sendTxDetailsPending, sendTxDetailsConfirmed], + [UniverseChainId.Base as WalletChainId]: [ + sendTxDetailsPending, + sendTxDetailsConfirmed, + ], + [UniverseChainId.Mainnet as WalletChainId]: [ + sendTxDetailsConfirmed, + sendTxDetailsFailed, + ], + [UniverseChainId.Bnb as WalletChainId]: [ + sendTxDetailsPending, + sendTxDetailsConfirmed, + ], }, }, }), diff --git a/apps/mobile/src/components/RemoveWallet/RemoveWalletModal.tsx b/apps/mobile/src/components/RemoveWallet/RemoveWalletModal.tsx index 896dc9e9af5..758c391223c 100644 --- a/apps/mobile/src/components/RemoveWallet/RemoveWalletModal.tsx +++ b/apps/mobile/src/components/RemoveWallet/RemoveWalletModal.tsx @@ -1,8 +1,7 @@ import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { useAnimatedStyle, withTiming } from 'react-native-reanimated' -import { useDispatch } from 'react-redux' -import { useAppSelector } from 'src/app/hooks' +import { useAppDispatch, useAppSelector } from 'src/app/hooks' import { navigate } from 'src/app/navigation/rootNavigation' import { AssociatedAccountsList } from 'src/components/RemoveWallet/AssociatedAccountsList' import { RemoveLastMnemonicWalletFooter } from 'src/components/RemoveWallet/RemoveLastMnemonicWalletFooter' @@ -30,7 +29,7 @@ import { setFinishedOnboarding } from 'wallet/src/features/wallet/slice' export function RemoveWalletModal(): JSX.Element | null { const { t } = useTranslation() const colors = useSporeColors() - const dispatch = useDispatch() + const dispatch = useAppDispatch() const addressToAccount = useAccounts() const associatedAccounts = useAppSelector(selectSignerMnemonicAccounts) diff --git a/apps/mobile/src/components/RestoreWalletModal/RestoreWalletModal.tsx b/apps/mobile/src/components/RestoreWalletModal/RestoreWalletModal.tsx index 6445b7550db..82051d7da9d 100644 --- a/apps/mobile/src/components/RestoreWalletModal/RestoreWalletModal.tsx +++ b/apps/mobile/src/components/RestoreWalletModal/RestoreWalletModal.tsx @@ -1,6 +1,6 @@ import React from 'react' import { useTranslation } from 'react-i18next' -import { useDispatch } from 'react-redux' +import { useAppDispatch } from 'src/app/hooks' import { navigate } from 'src/app/navigation/rootNavigation' import { closeAllModals, closeModal } from 'src/features/modals/modalSlice' import { Button, Flex, Text, useSporeColors } from 'ui/src' @@ -15,7 +15,7 @@ import { MobileScreens, OnboardingScreens } from 'uniswap/src/types/screens/mobi export function RestoreWalletModal(): JSX.Element | null { const { t } = useTranslation() const colors = useSporeColors() - const dispatch = useDispatch() + const dispatch = useAppDispatch() const onDismiss = (): void => { dispatch(closeModal({ name: ModalName.RestoreWallet })) diff --git a/apps/mobile/src/components/Settings/SettingsRow.tsx b/apps/mobile/src/components/Settings/SettingsRow.tsx index 01d7dbf911b..b3895b5e547 100644 --- a/apps/mobile/src/components/Settings/SettingsRow.tsx +++ b/apps/mobile/src/components/Settings/SettingsRow.tsx @@ -1,7 +1,6 @@ import { NavigatorScreenParams } from '@react-navigation/core' import React from 'react' import { ValueOf } from 'react-native-gesture-handler/lib/typescript/typeUtils' -import { useDispatch } from 'react-redux' import { OnboardingStackNavigationProp, OnboardingStackParamList, @@ -14,9 +13,10 @@ import { RotatableChevron } from 'ui/src/components/icons' import { iconSizes } from 'ui/src/theme' import { ModalName } from 'uniswap/src/features/telemetry/constants' import { MobileScreens } from 'uniswap/src/types/screens/mobile' -import { openUri } from 'uniswap/src/utils/linking' import { Switch } from 'wallet/src/components/buttons/Switch' import { Arrow } from 'wallet/src/components/icons/Arrow' +import { useAppDispatch } from 'wallet/src/state' +import { openUri } from 'wallet/src/utils/linking' export interface SettingsSection { subTitle: string @@ -67,7 +67,7 @@ export function SettingsRow({ navigation, }: SettingsRowProps): JSX.Element { const colors = useSporeColors() - const dispatch = useDispatch() + const dispatch = useAppDispatch() const handleRow = async (): Promise => { if (onToggle) { diff --git a/apps/mobile/src/components/TokenBalanceList/TokenBalanceItemContextMenu.tsx b/apps/mobile/src/components/TokenBalanceList/TokenBalanceItemContextMenu.tsx index 09b3cc3d30c..f19d8be83bd 100644 --- a/apps/mobile/src/components/TokenBalanceList/TokenBalanceItemContextMenu.tsx +++ b/apps/mobile/src/components/TokenBalanceList/TokenBalanceItemContextMenu.tsx @@ -1,7 +1,6 @@ import React, { memo, useMemo } from 'react' import ContextMenu from 'react-native-context-menu-view' import { borderRadii } from 'ui/src/theme' -import { SafetyLevel } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' import { PortfolioBalance } from 'uniswap/src/features/dataApi/types' import { useTokenContextMenu } from 'wallet/src/features/portfolio/useTokenContextMenu' @@ -14,7 +13,6 @@ export const TokenBalanceItemContextMenu = memo(function _TokenBalanceItem({ }) { const { menuActions, onContextMenuPress } = useTokenContextMenu({ currencyId: portfolioBalance.currencyInfo.currencyId, - isBlocked: portfolioBalance.currencyInfo.safetyLevel === SafetyLevel.Blocked, portfolioBalance, }) diff --git a/apps/mobile/src/components/TokenBalanceList/TokenBalanceList.tsx b/apps/mobile/src/components/TokenBalanceList/TokenBalanceList.tsx index 09eb1ea3e1f..6dba5b58aa6 100644 --- a/apps/mobile/src/components/TokenBalanceList/TokenBalanceList.tsx +++ b/apps/mobile/src/components/TokenBalanceList/TokenBalanceList.tsx @@ -61,7 +61,6 @@ export const TokenBalanceListInner = forwardRef, T refreshing, headerHeight = 0, onRefresh, - testID, }, ref, ) { @@ -125,9 +124,7 @@ export const TokenBalanceListInner = forwardRef, T // In order to avoid unnecessary re-renders of the entire FlatList, the `renderItem` function should never change. // That's why we use a context provider so that each row can read from there instead of passing down new props every time the data changes. const renderItem = useCallback( - ({ item, index }: { item: TokenBalanceListRow; index: number }): JSX.Element => ( - - ), + ({ item }: { item: TokenBalanceListRow }): JSX.Element => , [], ) @@ -212,7 +209,6 @@ export const TokenBalanceListInner = forwardRef, T renderItem={renderItem} scrollEventThrottle={containerProps?.scrollEventThrottle ?? TAB_VIEW_SCROLL_THROTTLE} showsVerticalScrollIndicator={false} - testID={testID} updateCellsBatchingPeriod={10} windowSize={isFocused ? 10 : 3} onContentSizeChange={onContentSizeChange} @@ -226,13 +222,7 @@ export const TokenBalanceListInner = forwardRef, T }, ) -const TokenBalanceItemRow = memo(function TokenBalanceItemRow({ - item, - index, -}: { - item: TokenBalanceListRow - index?: number -}) { +const TokenBalanceItemRow = memo(function TokenBalanceItemRow({ item }: { item: TokenBalanceListRow }) { const { balancesById, hiddenTokensCount, @@ -272,7 +262,6 @@ const TokenBalanceItemRow = memo(function TokenBalanceItemRow({ => { diff --git a/apps/mobile/src/components/TokenDetails/TokenBalances.tsx b/apps/mobile/src/components/TokenDetails/TokenBalances.tsx index 91bbe252934..e07f223e637 100644 --- a/apps/mobile/src/components/TokenDetails/TokenBalances.tsx +++ b/apps/mobile/src/components/TokenDetails/TokenBalances.tsx @@ -5,13 +5,13 @@ import { useTokenDetailsNavigation } from 'src/components/TokenDetails/hooks' import { Flex, Separator, Text, TouchableArea, useSporeColors } from 'ui/src' import { iconSizes } from 'ui/src/theme' import { TokenLogo } from 'uniswap/src/components/CurrencyLogo/TokenLogo' -import { InlineNetworkPill } from 'uniswap/src/components/network/NetworkPill' import { PortfolioBalance } from 'uniswap/src/features/dataApi/types' import Trace from 'uniswap/src/features/telemetry/Trace' import { MobileEventName } from 'uniswap/src/features/telemetry/constants' import { CurrencyId } from 'uniswap/src/types/currency' import { getSymbolDisplayText } from 'uniswap/src/utils/currency' import { NumberType } from 'utilities/src/format/types' +import { InlineNetworkPill } from 'wallet/src/components/network/NetworkPill' import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' import { AccountType } from 'wallet/src/features/wallet/accounts/types' import { useActiveAccount, useDisplayName } from 'wallet/src/features/wallet/hooks' diff --git a/apps/mobile/src/components/TokenDetails/TokenDetailsActionButtons.tsx b/apps/mobile/src/components/TokenDetails/TokenDetailsActionButtons.tsx index 862fc21a98d..fc9aa3cb5ec 100644 --- a/apps/mobile/src/components/TokenDetails/TokenDetailsActionButtons.tsx +++ b/apps/mobile/src/components/TokenDetails/TokenDetailsActionButtons.tsx @@ -4,7 +4,7 @@ import { Button, Flex, useSporeColors } from 'ui/src' import { opacify, validColor } from 'ui/src/theme' import Trace from 'uniswap/src/features/telemetry/Trace' import { ElementName, ElementNameType, SectionName } from 'uniswap/src/features/telemetry/constants' -import { getContrastPassingTextColor } from 'uniswap/src/utils/colors' +import { getContrastPassingTextColor } from 'wallet/src/utils/colors' function CTAButton({ title, diff --git a/apps/mobile/src/components/TokenDetails/hooks.test.ts b/apps/mobile/src/components/TokenDetails/hooks.test.ts index e585980d644..0360234c757 100644 --- a/apps/mobile/src/components/TokenDetails/hooks.test.ts +++ b/apps/mobile/src/components/TokenDetails/hooks.test.ts @@ -1,8 +1,8 @@ import { useCrossChainBalances, useTokenDetailsNavigation } from 'src/components/TokenDetails/hooks' import { preloadedMobileState } from 'src/test/fixtures' import { act, renderHook, waitFor } from 'src/test/test-utils' -import { currencyIdToContractInput } from 'uniswap/src/features/dataApi/utils' import { MobileScreens } from 'uniswap/src/types/screens/mobile' +import { currencyIdToContractInput } from 'wallet/src/features/dataApi/utils' import { SAMPLE_CURRENCY_ID_1, portfolio, @@ -41,7 +41,7 @@ describe(useCrossChainBalances, () => { expect(result.current).toEqual( expect.objectContaining({ currentChainBalance: null, - }), + }) ) }) @@ -51,16 +51,19 @@ describe(useCrossChainBalances, () => { const { resolvers } = queryResolvers({ portfolios: () => [Portfolio], }) - const { result } = renderHook(() => useCrossChainBalances(currentChainBalance.currencyInfo.currencyId, null), { - preloadedState: preloadedMobileState(), - resolvers, - }) + const { result } = renderHook( + () => useCrossChainBalances(currentChainBalance.currencyInfo.currencyId, null), + { + preloadedState: preloadedMobileState(), + resolvers, + } + ) await waitFor(() => { expect(result.current).toEqual( expect.objectContaining({ currentChainBalance, - }), + }) ) }) }) @@ -77,12 +80,15 @@ describe(useCrossChainBalances, () => { expect(result.current).toEqual( expect.objectContaining({ otherChainBalances: null, - }), + }) ) }) it('does not include current chain balance in other chain balances', async () => { - const tokenBalances = [tokenBalance({ token: usdcBaseToken() }), tokenBalance({ token: usdcArbitrumToken() })] + const tokenBalances = [ + tokenBalance({ token: usdcBaseToken() }), + tokenBalance({ token: usdcArbitrumToken() }), + ] const bridgeInfo = tokenBalances.map((balance) => ({ chain: balance.token.chain, @@ -101,11 +107,13 @@ describe(useCrossChainBalances, () => { { preloadedState: preloadedMobileState(), resolvers, - }, + } ) await waitFor(() => { - expect(result.current).toEqual(expect.objectContaining({ currentChainBalance, otherChainBalances })) + expect(result.current).toEqual( + expect.objectContaining({ currentChainBalance, otherChainBalances }) + ) }) }) }) diff --git a/apps/mobile/src/components/TokenDetails/hooks.ts b/apps/mobile/src/components/TokenDetails/hooks.ts index 1e5775b0a1e..17347681a22 100644 --- a/apps/mobile/src/components/TokenDetails/hooks.ts +++ b/apps/mobile/src/components/TokenDetails/hooks.ts @@ -7,9 +7,9 @@ import { } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' import { fromGraphQLChain } from 'uniswap/src/features/chains/utils' import { PortfolioBalance } from 'uniswap/src/features/dataApi/types' -import { currencyIdToContractInput } from 'uniswap/src/features/dataApi/utils' import { CurrencyId } from 'uniswap/src/types/currency' import { MobileScreens } from 'uniswap/src/types/screens/mobile' +import { currencyIdToContractInput } from 'wallet/src/features/dataApi/utils' import { buildCurrencyId, buildNativeCurrencyId, currencyIdToChain } from 'wallet/src/utils/currencyId' /** Helper hook to retrieve balances across chains for a given currency, for the active account. */ diff --git a/apps/mobile/src/components/TokenSelector/TokenFiatOnRampList.tsx b/apps/mobile/src/components/TokenSelector/TokenFiatOnRampList.tsx index 092fe8f62e0..b7f196bbb98 100644 --- a/apps/mobile/src/components/TokenSelector/TokenFiatOnRampList.tsx +++ b/apps/mobile/src/components/TokenSelector/TokenFiatOnRampList.tsx @@ -1,17 +1,14 @@ import { BottomSheetFlatList } from '@gorhom/bottom-sheet' import React, { memo, useCallback, useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' -import { Keyboard, ListRenderItemInfo } from 'react-native' +import { ListRenderItemInfo } from 'react-native' import { Flex, Inset, Loader } from 'ui/src' import { BaseCard } from 'uniswap/src/components/BaseCard/BaseCard' -import { TokenOptionItem } from 'uniswap/src/components/TokenSelector/TokenOptionItem' -import { useBottomSheetFocusHook } from 'uniswap/src/components/modals/hooks' import { FiatOnRampCurrency } from 'uniswap/src/features/fiatOnRamp/types' import { UniverseChainId } from 'uniswap/src/types/chains' import { CurrencyId } from 'uniswap/src/types/currency' -import { NumberType } from 'utilities/src/format/types' -import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' -import { useTokenWarningDismissed } from 'wallet/src/features/tokens/safetyHooks' +import { TokenOptionItem } from 'wallet/src/components/TokenSelector/TokenOptionItem' +import { useBottomSheetFocusHook } from 'wallet/src/components/modals/hooks' interface Props { onSelectCurrency: (currency: FiatOnRampCurrency) => void @@ -37,8 +34,6 @@ function TokenOptionItemWrapper({ [currencyInfo], ) const onPress = useCallback(() => onSelectCurrency?.(currency), [currency, onSelectCurrency]) - const { tokenWarningDismissed, dismissWarningCallback } = useTokenWarningDismissed(currencyInfo?.currencyId) - const { convertFiatAmountFormatted, formatNumberOrString } = useLocalizationContext() if (!option) { return null @@ -46,15 +41,9 @@ function TokenOptionItemWrapper({ return ( Keyboard.dismiss()} onPress={onPress} /> ) diff --git a/apps/mobile/src/components/Requests/ConnectedDapps/ConnectedDappsList.tsx b/apps/mobile/src/components/WalletConnect/ConnectedDapps/ConnectedDappsList.tsx similarity index 93% rename from apps/mobile/src/components/Requests/ConnectedDapps/ConnectedDappsList.tsx rename to apps/mobile/src/components/WalletConnect/ConnectedDapps/ConnectedDappsList.tsx index c87a625b624..d0259062591 100644 --- a/apps/mobile/src/components/Requests/ConnectedDapps/ConnectedDappsList.tsx +++ b/apps/mobile/src/components/WalletConnect/ConnectedDapps/ConnectedDappsList.tsx @@ -2,9 +2,9 @@ import React, { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { FlatList, StyleSheet } from 'react-native' import { FadeIn, FadeOut } from 'react-native-reanimated' -import { useDispatch } from 'react-redux' -import { DappConnectedNetworkModal } from 'src/components/Requests/ConnectedDapps/DappConnectedNetworksModal' -import { DappConnectionItem } from 'src/components/Requests/ConnectedDapps/DappConnectionItem' +import { useAppDispatch } from 'src/app/hooks' +import { DappConnectedNetworkModal } from 'src/components/WalletConnect/ConnectedDapps/DappConnectedNetworksModal' +import { DappConnectionItem } from 'src/components/WalletConnect/ConnectedDapps/DappConnectionItem' import { BackButton } from 'src/components/buttons/BackButton' import { openModal } from 'src/features/modals/modalSlice' import { WalletConnectSession, removePendingSession } from 'src/features/walletConnect/walletConnectSlice' @@ -22,7 +22,7 @@ type ConnectedDappsProps = { } export function ConnectedDappsList({ backButton, sessions }: ConnectedDappsProps): JSX.Element { - const dispatch = useDispatch() + const dispatch = useAppDispatch() const { t } = useTranslation() const { fullHeight } = useDeviceDimensions() const [isEditing, setIsEditing] = useState(false) diff --git a/apps/mobile/src/components/Requests/ConnectedDapps/DappConnectedNetworksModal.tsx b/apps/mobile/src/components/WalletConnect/ConnectedDapps/DappConnectedNetworksModal.tsx similarity index 96% rename from apps/mobile/src/components/Requests/ConnectedDapps/DappConnectedNetworksModal.tsx rename to apps/mobile/src/components/WalletConnect/ConnectedDapps/DappConnectedNetworksModal.tsx index bce5adb51ec..0409dd49391 100644 --- a/apps/mobile/src/components/Requests/ConnectedDapps/DappConnectedNetworksModal.tsx +++ b/apps/mobile/src/components/WalletConnect/ConnectedDapps/DappConnectedNetworksModal.tsx @@ -2,8 +2,8 @@ import { getSdkError } from '@walletconnect/utils' import React from 'react' import { Trans, useTranslation } from 'react-i18next' import 'react-native-reanimated' -import { useDispatch } from 'react-redux' -import { DappHeaderIcon } from 'src/components/Requests/DappHeaderIcon' +import { useAppDispatch } from 'src/app/hooks' +import { DappHeaderIcon } from 'src/components/WalletConnect/DappHeaderIcon' import { wcWeb3Wallet } from 'src/features/walletConnect/saga' import { WalletConnectSession, removeSession } from 'src/features/walletConnect/walletConnectSlice' import { Button, Flex, Text } from 'ui/src' @@ -26,7 +26,7 @@ interface DappConnectedNetworkModalProps { export function DappConnectedNetworkModal({ session, onClose }: DappConnectedNetworkModalProps): JSX.Element { const { t } = useTranslation() const address = useActiveAccountAddressWithThrow() - const dispatch = useDispatch() + const dispatch = useAppDispatch() const { dapp, id } = session const onDisconnect = async (): Promise => { diff --git a/apps/mobile/src/components/Requests/ConnectedDapps/DappConnectionItem.tsx b/apps/mobile/src/components/WalletConnect/ConnectedDapps/DappConnectionItem.tsx similarity index 95% rename from apps/mobile/src/components/Requests/ConnectedDapps/DappConnectionItem.tsx rename to apps/mobile/src/components/WalletConnect/ConnectedDapps/DappConnectionItem.tsx index 70fac046a8a..ba21b45ba84 100644 --- a/apps/mobile/src/components/Requests/ConnectedDapps/DappConnectionItem.tsx +++ b/apps/mobile/src/components/WalletConnect/ConnectedDapps/DappConnectionItem.tsx @@ -5,18 +5,18 @@ import { NativeSyntheticEvent, StyleSheet } from 'react-native' import ContextMenu, { ContextMenuOnPressNativeEvent } from 'react-native-context-menu-view' import 'react-native-reanimated' import { FadeIn, FadeOut } from 'react-native-reanimated' -import { useDispatch } from 'react-redux' -import { DappHeaderIcon } from 'src/components/Requests/DappHeaderIcon' +import { useAppDispatch } from 'src/app/hooks' +import { DappHeaderIcon } from 'src/components/WalletConnect/DappHeaderIcon' import { wcWeb3Wallet } from 'src/features/walletConnect/saga' import { WalletConnectSession, removeSession } from 'src/features/walletConnect/walletConnectSlice' import { disableOnPress } from 'src/utils/disableOnPress' import { AnimatedTouchableArea, Flex, ImpactFeedbackStyle, Text, TouchableArea } from 'ui/src' import { iconSizes, spacing } from 'ui/src/theme' -import { NetworkLogos } from 'uniswap/src/components/network/NetworkLogos' import { TestID } from 'uniswap/src/test/fixtures/testIDs' import { WalletConnectEvent } from 'uniswap/src/types/walletConnect' import { logger } from 'utilities/src/logger/logger' import { ONE_SECOND_MS } from 'utilities/src/time/time' +import { NetworkLogos } from 'wallet/src/components/network/NetworkLogos' import { pushNotification } from 'wallet/src/features/notifications/slice' import { AppNotificationType } from 'wallet/src/features/notifications/types' import { useActiveAccountAddressWithThrow } from 'wallet/src/features/wallet/hooks' @@ -32,7 +32,7 @@ export function DappConnectionItem({ }): JSX.Element { const { t } = useTranslation() const { dapp } = session - const dispatch = useDispatch() + const dispatch = useAppDispatch() const address = useActiveAccountAddressWithThrow() const onDisconnect = async (): Promise => { @@ -134,7 +134,6 @@ export function DappConnectionItem({ borderRadius="$roundedFull" chains={session.chains} p="$spacing8" - size={iconSizes.icon16} /> diff --git a/apps/mobile/src/components/Requests/DappHeaderIcon.tsx b/apps/mobile/src/components/WalletConnect/DappHeaderIcon.tsx similarity index 100% rename from apps/mobile/src/components/Requests/DappHeaderIcon.tsx rename to apps/mobile/src/components/WalletConnect/DappHeaderIcon.tsx diff --git a/apps/mobile/src/components/Requests/ModalWithOverlay/ModalWithOverlay.tsx b/apps/mobile/src/components/WalletConnect/ModalWithOverlay/ModalWithOverlay.tsx similarity index 98% rename from apps/mobile/src/components/Requests/ModalWithOverlay/ModalWithOverlay.tsx rename to apps/mobile/src/components/WalletConnect/ModalWithOverlay/ModalWithOverlay.tsx index b0b5550e94e..6be33be4328 100644 --- a/apps/mobile/src/components/Requests/ModalWithOverlay/ModalWithOverlay.tsx +++ b/apps/mobile/src/components/WalletConnect/ModalWithOverlay/ModalWithOverlay.tsx @@ -12,7 +12,7 @@ import { ViewStyle, } from 'react-native' import { AnimatedStyle, useDerivedValue } from 'react-native-reanimated' -import { ScrollDownOverlay } from 'src/components/Requests/ModalWithOverlay/ScrollDownOverlay' +import { ScrollDownOverlay } from 'src/components/WalletConnect/ModalWithOverlay/ScrollDownOverlay' import { Button, Flex, useDeviceInsets } from 'ui/src' import { spacing } from 'ui/src/theme' import { BottomSheetModal } from 'uniswap/src/components/modals/BottomSheetModal' diff --git a/apps/mobile/src/components/Requests/ModalWithOverlay/ScrollDownOverlay.tsx b/apps/mobile/src/components/WalletConnect/ModalWithOverlay/ScrollDownOverlay.tsx similarity index 100% rename from apps/mobile/src/components/Requests/ModalWithOverlay/ScrollDownOverlay.tsx rename to apps/mobile/src/components/WalletConnect/ModalWithOverlay/ScrollDownOverlay.tsx diff --git a/apps/mobile/src/components/Requests/RequestModal/ClientDetails.tsx b/apps/mobile/src/components/WalletConnect/RequestModal/ClientDetails.tsx similarity index 89% rename from apps/mobile/src/components/Requests/RequestModal/ClientDetails.tsx rename to apps/mobile/src/components/WalletConnect/RequestModal/ClientDetails.tsx index 156a8c25602..61a947265cc 100644 --- a/apps/mobile/src/components/Requests/RequestModal/ClientDetails.tsx +++ b/apps/mobile/src/components/WalletConnect/RequestModal/ClientDetails.tsx @@ -1,7 +1,7 @@ import React from 'react' import { LinkButton } from 'src/components/buttons/LinkButton' -import { DappHeaderIcon } from 'src/components/Requests/DappHeaderIcon' -import { HeaderText } from 'src/components/Requests/RequestModal/HeaderText' +import { DappHeaderIcon } from 'src/components/WalletConnect/DappHeaderIcon' +import { HeaderText } from 'src/components/WalletConnect/RequestModal/HeaderText' import { WalletConnectRequest } from 'src/features/walletConnect/walletConnectSlice' import { Flex, useSporeColors } from 'ui/src' import { iconSizes } from 'ui/src/theme' diff --git a/apps/mobile/src/components/Requests/RequestModal/HeaderText.tsx b/apps/mobile/src/components/WalletConnect/RequestModal/HeaderText.tsx similarity index 100% rename from apps/mobile/src/components/Requests/RequestModal/HeaderText.tsx rename to apps/mobile/src/components/WalletConnect/RequestModal/HeaderText.tsx diff --git a/apps/mobile/src/components/Requests/RequestModal/KidSuperCheckinModal.tsx b/apps/mobile/src/components/WalletConnect/RequestModal/KidSuperCheckinModal.tsx similarity index 89% rename from apps/mobile/src/components/Requests/RequestModal/KidSuperCheckinModal.tsx rename to apps/mobile/src/components/WalletConnect/RequestModal/KidSuperCheckinModal.tsx index 5db87b36a44..0e4b2ccef7d 100644 --- a/apps/mobile/src/components/Requests/RequestModal/KidSuperCheckinModal.tsx +++ b/apps/mobile/src/components/WalletConnect/RequestModal/KidSuperCheckinModal.tsx @@ -1,9 +1,9 @@ import { useBottomSheetInternal } from '@gorhom/bottom-sheet' import { useTranslation } from 'react-i18next' import Animated, { useAnimatedStyle } from 'react-native-reanimated' -import { ModalWithOverlay } from 'src/components/Requests/ModalWithOverlay/ModalWithOverlay' -import { RequestDetailsContent } from 'src/components/Requests/RequestModal/RequestDetails' -import { useUwuLinkContractAllowlist } from 'src/components/Requests/ScanSheet/util' +import { ModalWithOverlay } from 'src/components/WalletConnect/ModalWithOverlay/ModalWithOverlay' +import { RequestDetailsContent } from 'src/components/WalletConnect/RequestModal/RequestDetails' +import { useUwuLinkContractAllowlist } from 'src/components/WalletConnect/ScanSheet/util' import { SignRequest } from 'src/features/walletConnect/walletConnectSlice' import { Flex, useIsDarkMode } from 'ui/src' import { spacing } from 'ui/src/theme' diff --git a/apps/mobile/src/components/Requests/RequestModal/RequestDetails.tsx b/apps/mobile/src/components/WalletConnect/RequestModal/RequestDetails.tsx similarity index 94% rename from apps/mobile/src/components/Requests/RequestModal/RequestDetails.tsx rename to apps/mobile/src/components/WalletConnect/RequestModal/RequestDetails.tsx index e1834f6d89d..e1bdbc3249e 100644 --- a/apps/mobile/src/components/Requests/RequestModal/RequestDetails.tsx +++ b/apps/mobile/src/components/WalletConnect/RequestModal/RequestDetails.tsx @@ -55,7 +55,7 @@ const MAX_TYPED_DATA_PARSE_DEPTH = 3 // eslint-disable-next-line @typescript-eslint/no-explicit-any const getParsedObjectDisplay = (chainId: number, obj: any, depth = 0): JSX.Element => { if (depth === MAX_TYPED_DATA_PARSE_DEPTH + 1) { - return ... + return ... } return ( @@ -66,7 +66,7 @@ const getParsedObjectDisplay = (chainId: number, obj: any, depth = 0): JSX.Eleme if (typeof childValue === 'object') { return ( - + {objKey} {getParsedObjectDisplay(chainId, childValue, depth + 1)} @@ -76,17 +76,15 @@ const getParsedObjectDisplay = (chainId: number, obj: any, depth = 0): JSX.Eleme if (typeof childValue === 'string') { return ( - - + + {objKey} {getValidAddress(childValue, true) ? ( - - - + ) : ( - + {childValue} )} diff --git a/apps/mobile/src/components/Requests/RequestModal/UwULinkErc20SendModal.tsx b/apps/mobile/src/components/WalletConnect/RequestModal/UwULinkErc20SendModal.tsx similarity index 97% rename from apps/mobile/src/components/Requests/RequestModal/UwULinkErc20SendModal.tsx rename to apps/mobile/src/components/WalletConnect/RequestModal/UwULinkErc20SendModal.tsx index 1eefd9fdda1..d25fc089c3c 100644 --- a/apps/mobile/src/components/Requests/RequestModal/UwULinkErc20SendModal.tsx +++ b/apps/mobile/src/components/WalletConnect/RequestModal/UwULinkErc20SendModal.tsx @@ -2,7 +2,7 @@ import { useBottomSheetInternal } from '@gorhom/bottom-sheet' import { formatUnits } from 'ethers/lib/utils' import { useTranslation } from 'react-i18next' import Animated, { useAnimatedStyle } from 'react-native-reanimated' -import { ModalWithOverlay } from 'src/components/Requests/ModalWithOverlay/ModalWithOverlay' +import { ModalWithOverlay } from 'src/components/WalletConnect/ModalWithOverlay/ModalWithOverlay' import { UwuLinkErc20Request } from 'src/features/walletConnect/walletConnectSlice' import { Flex, SpinningLoader, Text, useIsDarkMode } from 'ui/src' import { iconSizes, spacing } from 'ui/src/theme' @@ -10,7 +10,6 @@ import { TokenLogo } from 'uniswap/src/components/CurrencyLogo/TokenLogo' import { UNIVERSE_CHAIN_INFO } from 'uniswap/src/constants/chains' import { CurrencyInfo } from 'uniswap/src/features/dataApi/types' import { ModalName } from 'uniswap/src/features/telemetry/constants' -import { NativeCurrency } from 'uniswap/src/features/tokens/NativeCurrency' import { buildCurrencyId } from 'uniswap/src/utils/currencyId' import { NumberType } from 'utilities/src/format/types' import { NetworkFee } from 'wallet/src/components/network/NetworkFee' @@ -18,6 +17,7 @@ import { GasFeeResult } from 'wallet/src/features/gas/types' import { RemoteImage } from 'wallet/src/features/images/RemoteImage' import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' import { useOnChainCurrencyBalance } from 'wallet/src/features/portfolio/api' +import { NativeCurrency } from 'wallet/src/features/tokens/NativeCurrency' import { useCurrencyInfo } from 'wallet/src/features/tokens/useCurrencyInfo' import { useActiveAccountAddressWithThrow } from 'wallet/src/features/wallet/hooks' diff --git a/apps/mobile/src/components/Requests/RequestModal/WalletConnectRequestModal.tsx b/apps/mobile/src/components/WalletConnect/RequestModal/WalletConnectRequestModal.tsx similarity index 92% rename from apps/mobile/src/components/Requests/RequestModal/WalletConnectRequestModal.tsx rename to apps/mobile/src/components/WalletConnect/RequestModal/WalletConnectRequestModal.tsx index db9d610cf01..07a5ca086ab 100644 --- a/apps/mobile/src/components/Requests/RequestModal/WalletConnectRequestModal.tsx +++ b/apps/mobile/src/components/WalletConnect/RequestModal/WalletConnectRequestModal.tsx @@ -3,16 +3,15 @@ import { getSdkError } from '@walletconnect/utils' import { providers } from 'ethers' import React, { useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' -import { useDispatch } from 'react-redux' -import { useAppSelector } from 'src/app/hooks' -import { ModalWithOverlay } from 'src/components/Requests/ModalWithOverlay/ModalWithOverlay' -import { KidSuperCheckinModal } from 'src/components/Requests/RequestModal/KidSuperCheckinModal' -import { UwULinkErc20SendModal } from 'src/components/Requests/RequestModal/UwULinkErc20SendModal' +import { useAppDispatch, useAppSelector } from 'src/app/hooks' +import { ModalWithOverlay } from 'src/components/WalletConnect/ModalWithOverlay/ModalWithOverlay' +import { KidSuperCheckinModal } from 'src/components/WalletConnect/RequestModal/KidSuperCheckinModal' +import { UwULinkErc20SendModal } from 'src/components/WalletConnect/RequestModal/UwULinkErc20SendModal' import { WalletConnectRequestModalContent, methodCostsGas, -} from 'src/components/Requests/RequestModal/WalletConnectRequestModalContent' -import { useHasSufficientFunds } from 'src/components/Requests/RequestModal/hooks' +} from 'src/components/WalletConnect/RequestModal/WalletConnectRequestModalContent' +import { useHasSufficientFunds } from 'src/components/WalletConnect/RequestModal/hooks' import { useBiometricAppSettings, useBiometricPrompt } from 'src/features/biometrics/hooks' import { returnToPreviousApp } from 'src/features/walletConnect/WalletConnect' import { wcWeb3Wallet } from 'src/features/walletConnect/saga' @@ -99,7 +98,7 @@ export function WalletConnectRequestModal({ onClose, request }: Props): JSX.Elem } const confirmEnabled = checkConfirmEnabled() - const dispatch = useDispatch() + const dispatch = useAppDispatch() /** * TODO: [MOB-239] implement this behavior in a less janky way. Ideally if we can distinguish between `onClose` being called programmatically and `onClose` as a results of a user dismissing the modal then we can determine what this value should be without this class variable. * Indicates that the modal can reject the request when the modal happens. This will be false when the modal closes as a result of the user explicitly confirming or rejecting a request and true otherwise. @@ -137,14 +136,11 @@ export function WalletConnectRequestModal({ onClose, request }: Props): JSX.Elem } const onConfirm = async (): Promise => { - if (!confirmEnabled || !signerAccount) { + if (!confirmEnabled || !signerAccount || !tx) { return } if (request.type === EthMethod.EthSendTransaction || request.type === UwULinkMethod.Erc20Send) { - if (!tx) { - return - } const txnWithFormattedGasEstimates = formatExternalTxnWithGasEstimates({ transaction: tx, gasFeeResult: gasFee, diff --git a/apps/mobile/src/components/Requests/RequestModal/WalletConnectRequestModalContent.tsx b/apps/mobile/src/components/WalletConnect/RequestModal/WalletConnectRequestModalContent.tsx similarity index 95% rename from apps/mobile/src/components/Requests/RequestModal/WalletConnectRequestModalContent.tsx rename to apps/mobile/src/components/WalletConnect/RequestModal/WalletConnectRequestModalContent.tsx index 253121d298e..f1fd7ad1aa1 100644 --- a/apps/mobile/src/components/Requests/RequestModal/WalletConnectRequestModalContent.tsx +++ b/apps/mobile/src/components/WalletConnect/RequestModal/WalletConnectRequestModalContent.tsx @@ -4,8 +4,8 @@ import React, { PropsWithChildren } from 'react' import { useTranslation } from 'react-i18next' import { StyleProp, ViewStyle } from 'react-native' import Animated, { useAnimatedStyle } from 'react-native-reanimated' -import { ClientDetails, PermitInfo } from 'src/components/Requests/RequestModal/ClientDetails' -import { RequestDetails } from 'src/components/Requests/RequestModal/RequestDetails' +import { ClientDetails, PermitInfo } from 'src/components/WalletConnect/RequestModal/ClientDetails' +import { RequestDetails } from 'src/components/WalletConnect/RequestModal/RequestDetails' import { SignRequest, TransactionRequest, @@ -16,12 +16,12 @@ import { Flex, Text, useSporeColors } from 'ui/src' import AlertTriangle from 'ui/src/assets/icons/alert-triangle.svg' import { iconSizes } from 'ui/src/theme' import { BaseCard } from 'uniswap/src/components/BaseCard/BaseCard' -import { NativeCurrency } from 'uniswap/src/features/tokens/NativeCurrency' import { EthMethod, isPrimaryTypePermit } from 'uniswap/src/types/walletConnect' import { buildCurrencyId } from 'uniswap/src/utils/currencyId' import { logger } from 'utilities/src/logger/logger' import { useUSDValue } from 'wallet/src/features/gas/hooks' import { GasFeeResult } from 'wallet/src/features/gas/types' +import { NativeCurrency } from 'wallet/src/features/tokens/NativeCurrency' import { AddressFooter } from 'wallet/src/features/transactions/TransactionRequest/AddressFooter' import { NetworkFeeFooter } from 'wallet/src/features/transactions/TransactionRequest/NetworkFeeFooter' import { BlockedAddressWarning } from 'wallet/src/features/trm/BlockedAddressWarning' diff --git a/apps/mobile/src/components/Requests/RequestModal/hooks.ts b/apps/mobile/src/components/WalletConnect/RequestModal/hooks.ts similarity index 94% rename from apps/mobile/src/components/Requests/RequestModal/hooks.ts rename to apps/mobile/src/components/WalletConnect/RequestModal/hooks.ts index 2f8066028ac..f741c31bbde 100644 --- a/apps/mobile/src/components/Requests/RequestModal/hooks.ts +++ b/apps/mobile/src/components/WalletConnect/RequestModal/hooks.ts @@ -1,8 +1,8 @@ import { useMemo } from 'react' -import { NativeCurrency } from 'uniswap/src/features/tokens/NativeCurrency' import { UniverseChainId, WalletChainId } from 'uniswap/src/types/chains' import { GasFeeResult } from 'wallet/src/features/gas/types' import { useOnChainNativeCurrencyBalance } from 'wallet/src/features/portfolio/api' +import { NativeCurrency } from 'wallet/src/features/tokens/NativeCurrency' import { hasSufficientFundsIncludingGas } from 'wallet/src/features/transactions/utils' import { ValueType, getCurrencyAmount } from 'wallet/src/utils/getCurrencyAmount' diff --git a/apps/mobile/src/components/Requests/ScanSheet/PendingConnectionModal.tsx b/apps/mobile/src/components/WalletConnect/ScanSheet/PendingConnectionModal.tsx similarity index 95% rename from apps/mobile/src/components/Requests/ScanSheet/PendingConnectionModal.tsx rename to apps/mobile/src/components/WalletConnect/ScanSheet/PendingConnectionModal.tsx index 551fa807775..480b0c61421 100644 --- a/apps/mobile/src/components/Requests/ScanSheet/PendingConnectionModal.tsx +++ b/apps/mobile/src/components/WalletConnect/ScanSheet/PendingConnectionModal.tsx @@ -3,12 +3,11 @@ import { getSdkError } from '@walletconnect/utils' import React, { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import Animated, { useAnimatedStyle } from 'react-native-reanimated' -import { useDispatch } from 'react-redux' -import { useAppSelector } from 'src/app/hooks' -import { DappHeaderIcon } from 'src/components/Requests/DappHeaderIcon' -import { ModalWithOverlay } from 'src/components/Requests/ModalWithOverlay/ModalWithOverlay' -import { PendingConnectionSwitchAccountModal } from 'src/components/Requests/ScanSheet/PendingConnectionSwitchAccountModal' -import { truncateQueryParams } from 'src/components/Requests/ScanSheet/util' +import { useAppDispatch, useAppSelector } from 'src/app/hooks' +import { DappHeaderIcon } from 'src/components/WalletConnect/DappHeaderIcon' +import { ModalWithOverlay } from 'src/components/WalletConnect/ModalWithOverlay/ModalWithOverlay' +import { PendingConnectionSwitchAccountModal } from 'src/components/WalletConnect/ScanSheet/PendingConnectionSwitchAccountModal' +import { truncateQueryParams } from 'src/components/WalletConnect/ScanSheet/util' import { LinkButton } from 'src/components/buttons/LinkButton' import { returnToPreviousApp } from 'src/features/walletConnect/WalletConnect' import { wcWeb3Wallet } from 'src/features/walletConnect/saga' @@ -22,7 +21,6 @@ import { import { Flex, Text, TouchableArea, useSporeColors } from 'ui/src' import { Check, RotatableChevron, X } from 'ui/src/components/icons' import { iconSizes } from 'ui/src/theme' -import { NetworkLogos } from 'uniswap/src/components/network/NetworkLogos' import { MobileEventName, ModalName } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import { TestID } from 'uniswap/src/test/fixtures/testIDs' @@ -30,6 +28,7 @@ import { WalletChainId } from 'uniswap/src/types/chains' import { WCEventType, WCRequestOutcome, WalletConnectEvent } from 'uniswap/src/types/walletConnect' import { formatDappURL } from 'utilities/src/format/urls' import { ONE_SECOND_MS } from 'utilities/src/time/time' +import { NetworkLogos } from 'wallet/src/components/network/NetworkLogos' import { pushNotification } from 'wallet/src/features/notifications/slice' import { AppNotificationType } from 'wallet/src/features/notifications/types' import { AddressFooter } from 'wallet/src/features/transactions/TransactionRequest/AddressFooter' @@ -151,7 +150,7 @@ const SwitchAccountRow = ({ activeAddress, setModalState }: SwitchAccountProps): export const PendingConnectionModal = ({ pendingSession, onClose }: Props): JSX.Element => { const { t } = useTranslation() - const dispatch = useDispatch() + const dispatch = useAppDispatch() const activeAddress = useActiveAccountAddressWithThrow() const activeAccount = useActiveAccountWithThrow() const didOpenFromDeepLink = useAppSelector(selectDidOpenFromDeepLink) diff --git a/apps/mobile/src/components/Requests/ScanSheet/PendingConnectionSwitchAccountModal.tsx b/apps/mobile/src/components/WalletConnect/ScanSheet/PendingConnectionSwitchAccountModal.tsx similarity index 93% rename from apps/mobile/src/components/Requests/ScanSheet/PendingConnectionSwitchAccountModal.tsx rename to apps/mobile/src/components/WalletConnect/ScanSheet/PendingConnectionSwitchAccountModal.tsx index 85263b4621b..a46e395af34 100644 --- a/apps/mobile/src/components/Requests/ScanSheet/PendingConnectionSwitchAccountModal.tsx +++ b/apps/mobile/src/components/WalletConnect/ScanSheet/PendingConnectionSwitchAccountModal.tsx @@ -1,6 +1,6 @@ import React, { useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { SwitchAccountOption } from 'src/components/Requests/ScanSheet/SwitchAccountOption' +import { SwitchAccountOption } from 'src/components/WalletConnect/ScanSheet/SwitchAccountOption' import { Flex, Text } from 'ui/src' import { ActionSheetModal } from 'uniswap/src/components/modals/ActionSheetModal' import { ElementName, ModalName } from 'uniswap/src/features/telemetry/constants' diff --git a/apps/mobile/src/components/Requests/ScanSheet/PendingConnectionSwitchNetworkModal.tsx b/apps/mobile/src/components/WalletConnect/ScanSheet/PendingConnectionSwitchNetworkModal.tsx similarity index 100% rename from apps/mobile/src/components/Requests/ScanSheet/PendingConnectionSwitchNetworkModal.tsx rename to apps/mobile/src/components/WalletConnect/ScanSheet/PendingConnectionSwitchNetworkModal.tsx diff --git a/apps/mobile/src/components/Requests/ScanSheet/SwitchAccountOption.tsx b/apps/mobile/src/components/WalletConnect/ScanSheet/SwitchAccountOption.tsx similarity index 100% rename from apps/mobile/src/components/Requests/ScanSheet/SwitchAccountOption.tsx rename to apps/mobile/src/components/WalletConnect/ScanSheet/SwitchAccountOption.tsx diff --git a/apps/mobile/src/components/Requests/ScanSheet/WalletConnectModal.tsx b/apps/mobile/src/components/WalletConnect/ScanSheet/WalletConnectModal.tsx similarity index 98% rename from apps/mobile/src/components/Requests/ScanSheet/WalletConnectModal.tsx rename to apps/mobile/src/components/WalletConnect/ScanSheet/WalletConnectModal.tsx index 39a40355dd2..7bf42b92845 100644 --- a/apps/mobile/src/components/Requests/ScanSheet/WalletConnectModal.tsx +++ b/apps/mobile/src/components/WalletConnect/ScanSheet/WalletConnectModal.tsx @@ -2,10 +2,10 @@ import React, { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { Alert } from 'react-native' import 'react-native-reanimated' -import { useDispatch } from 'react-redux' +import { useAppDispatch } from 'src/app/hooks' import { useEagerExternalProfileRootNavigation } from 'src/app/navigation/hooks' import { QRCodeScanner } from 'src/components/QRCodeScanner/QRCodeScanner' -import { ConnectedDappsList } from 'src/components/Requests/ConnectedDapps/ConnectedDappsList' +import { ConnectedDappsList } from 'src/components/WalletConnect/ConnectedDapps/ConnectedDappsList' import { URIType, UWULINK_PREFIX, @@ -14,7 +14,7 @@ import { isAllowedUwuLinkRequest, toTokenTransferRequest, useUwuLinkContractAllowlist, -} from 'src/components/Requests/ScanSheet/util' +} from 'src/components/WalletConnect/ScanSheet/util' import { BackButtonView } from 'src/components/layout/BackButtonView' import { openDeepLink } from 'src/features/deepLinking/handleDeepLinkSaga' import { useWalletConnect } from 'src/features/walletConnect/useWalletConnect' @@ -54,7 +54,7 @@ export function WalletConnectModal({ const [currentScreenState, setCurrentScreenState] = useState(initialScreenState) const [shouldFreezeCamera, setShouldFreezeCamera] = useState(false) const { preload, navigate } = useEagerExternalProfileRootNavigation() - const dispatch = useDispatch() + const dispatch = useAppDispatch() const isUwULinkEnabled = useFeatureFlag(FeatureFlags.UwULink) const isScantasticEnabled = useFeatureFlag(FeatureFlags.Scantastic) diff --git a/apps/mobile/src/components/Requests/ScanSheet/util.test.ts b/apps/mobile/src/components/WalletConnect/ScanSheet/util.test.ts similarity index 97% rename from apps/mobile/src/components/Requests/ScanSheet/util.test.ts rename to apps/mobile/src/components/WalletConnect/ScanSheet/util.test.ts index fe61ac39333..d6f94a93e30 100644 --- a/apps/mobile/src/components/Requests/ScanSheet/util.test.ts +++ b/apps/mobile/src/components/WalletConnect/ScanSheet/util.test.ts @@ -1,5 +1,9 @@ import * as wcUtils from '@walletconnect/utils' -import { CUSTOM_UNI_QR_CODE_PREFIX, URIType, getSupportedURI } from 'src/components/Requests/ScanSheet/util' +import { + CUSTOM_UNI_QR_CODE_PREFIX, + URIType, + getSupportedURI, +} from 'src/components/WalletConnect/ScanSheet/util' import { wcAsParamInUniwapScheme, wcInUniwapScheme, diff --git a/apps/mobile/src/components/Requests/ScanSheet/util.ts b/apps/mobile/src/components/WalletConnect/ScanSheet/util.ts similarity index 92% rename from apps/mobile/src/components/Requests/ScanSheet/util.ts rename to apps/mobile/src/components/WalletConnect/ScanSheet/util.ts index 4567cd8461a..1eba555ae17 100644 --- a/apps/mobile/src/components/Requests/ScanSheet/util.ts +++ b/apps/mobile/src/components/WalletConnect/ScanSheet/util.ts @@ -6,9 +6,8 @@ import { UNISWAP_URL_SCHEME_WALLETCONNECT_AS_PARAM, UNISWAP_WALLETCONNECT_URL, } from 'src/features/deepLinking/constants' -import { AssetType } from 'uniswap/src/entities/assets' -import { DynamicConfigs, UwuLinkConfigKey } from 'uniswap/src/features/gating/configs' -import { useDynamicConfigValue } from 'uniswap/src/features/gating/hooks' +import { DynamicConfigs } from 'uniswap/src/features/gating/configs' +import { useDynamicConfig } from 'uniswap/src/features/gating/hooks' import { RPCType } from 'uniswap/src/types/chains' import { EthMethod, @@ -19,6 +18,7 @@ import { } from 'uniswap/src/types/walletConnect' import { areAddressesEqual, getValidAddress } from 'uniswap/src/utils/addresses' import { logger } from 'utilities/src/logger/logger' +import { AssetType } from 'wallet/src/entities/assets' import { ContractManager } from 'wallet/src/features/contracts/ContractManager' import { ProviderManager } from 'wallet/src/features/providers' import { ScantasticParams, ScantasticParamsSchema } from 'wallet/src/features/scantastic/types' @@ -151,26 +151,8 @@ function isUwULink(uri: string): boolean { // Gets the UWULink contract allow list from statsig dynamic config. // We can safely cast as long as the statsig config format matches our `UwuLinkAllowlist` type. export function useUwuLinkContractAllowlist(): UwULinkAllowlist { - return useDynamicConfigValue( - DynamicConfigs.UwuLink, - UwuLinkConfigKey.Allowlist, - { - contracts: [], - tokenRecipients: [], - }, - (x: unknown) => { - const hasFields = - x !== null && typeof x === 'object' && Object.hasOwn(x, 'contracts') && Object.hasOwn(x, 'tokenRecipients') - - if (!hasFields) { - return false - } - - const castedObj = x as { contracts: unknown; tokenRecipients: unknown } - - return Array.isArray(castedObj.contracts) && Array.isArray(castedObj.tokenRecipients) - }, - ) + const uwuLinkConfig = useDynamicConfig(DynamicConfigs.UwuLink) + return uwuLinkConfig.getValue('allowlist') as UwULinkAllowlist } export function findAllowedTokenRecipient( diff --git a/apps/mobile/src/components/Requests/WalletConnectModals.tsx b/apps/mobile/src/components/WalletConnect/WalletConnectModals.tsx similarity index 91% rename from apps/mobile/src/components/Requests/WalletConnectModals.tsx rename to apps/mobile/src/components/WalletConnect/WalletConnectModals.tsx index 8a0bf942ecb..46490cf4667 100644 --- a/apps/mobile/src/components/Requests/WalletConnectModals.tsx +++ b/apps/mobile/src/components/WalletConnect/WalletConnectModals.tsx @@ -1,9 +1,9 @@ import React, { useEffect } from 'react' import { useTranslation } from 'react-i18next' -import { useDispatch } from 'react-redux' -import { WalletConnectRequestModal } from 'src/components/Requests/RequestModal/WalletConnectRequestModal' -import { PendingConnectionModal } from 'src/components/Requests/ScanSheet/PendingConnectionModal' -import { WalletConnectModal } from 'src/components/Requests/ScanSheet/WalletConnectModal' +import { useAppDispatch } from 'src/app/hooks' +import { WalletConnectRequestModal } from 'src/components/WalletConnect/RequestModal/WalletConnectRequestModal' +import { PendingConnectionModal } from 'src/components/WalletConnect/ScanSheet/PendingConnectionModal' +import { WalletConnectModal } from 'src/components/WalletConnect/ScanSheet/WalletConnectModal' import { closeModal } from 'src/features/modals/modalSlice' import { useWalletConnect } from 'src/features/walletConnect/useWalletConnect' import { @@ -25,7 +25,7 @@ import { useActiveAccount, useActiveAccountAddressWithThrow, useSignerAccounts } export function WalletConnectModals(): JSX.Element { const activeAccount = useActiveAccount() - const dispatch = useDispatch() + const dispatch = useAppDispatch() const { pendingRequests, modalState, pendingSession } = useWalletConnect(activeAccount?.address) @@ -80,7 +80,7 @@ function RequestModal({ currRequest }: RequestModalProps): JSX.Element { const signerAccounts = useSignerAccounts() const activeAccountAddress = useActiveAccountAddressWithThrow() const { t } = useTranslation() - const dispatch = useDispatch() + const dispatch = useAppDispatch() const colors = useSporeColors() // TODO: Move returnToPreviousApp() call to onClose but ensure it is not called twice diff --git a/apps/mobile/src/components/accounts/AccountCardItem.tsx b/apps/mobile/src/components/accounts/AccountCardItem.tsx index ec1dcf25355..8d9affed99e 100644 --- a/apps/mobile/src/components/accounts/AccountCardItem.tsx +++ b/apps/mobile/src/components/accounts/AccountCardItem.tsx @@ -2,7 +2,7 @@ import { SharedEventName } from '@uniswap/analytics-events' import React, { useMemo } from 'react' import { useTranslation } from 'react-i18next' import ContextMenu from 'react-native-context-menu-view' -import { useDispatch } from 'react-redux' +import { useAppDispatch } from 'src/app/hooks' import { navigate } from 'src/app/navigation/rootNavigation' import { NotificationBadge } from 'src/components/notifications/Badge' import { closeModal, openModal } from 'src/features/modals/modalSlice' @@ -12,13 +12,13 @@ import { iconSizes } from 'ui/src/theme' import { ElementName, ModalName } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import { MobileScreens } from 'uniswap/src/types/screens/mobile' -import { setClipboard } from 'uniswap/src/utils/clipboard' import { NumberType } from 'utilities/src/format/types' import { AddressDisplay } from 'wallet/src/components/accounts/AddressDisplay' import { useAccountList } from 'wallet/src/features/accounts/hooks' import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' import { pushNotification } from 'wallet/src/features/notifications/slice' import { AppNotificationType, CopyNotificationType } from 'wallet/src/features/notifications/types' +import { setClipboard } from 'wallet/src/utils/clipboard' type AccountCardItemProps = { address: Address @@ -73,7 +73,7 @@ export function AccountCardItem({ }: AccountCardItemProps): JSX.Element { const { t } = useTranslation() - const dispatch = useDispatch() + const dispatch = useAppDispatch() const onPressCopyAddress = async (): Promise => { await HapticFeedback.impact() diff --git a/apps/mobile/src/components/accounts/AccountHeader.test.tsx b/apps/mobile/src/components/accounts/AccountHeader.test.tsx index 10369dd039f..9bc71ffe413 100644 --- a/apps/mobile/src/components/accounts/AccountHeader.test.tsx +++ b/apps/mobile/src/components/accounts/AccountHeader.test.tsx @@ -5,7 +5,6 @@ import { AccountHeader } from 'src/components/accounts/AccountHeader' import { fireEvent, render, screen, waitFor, within } from 'src/test/test-utils' import { ModalName } from 'uniswap/src/features/telemetry/constants' import { ON_PRESS_EVENT_PAYLOAD } from 'uniswap/src/test/fixtures' -import { TestID } from 'uniswap/src/test/fixtures/testIDs' import { MobileScreens } from 'uniswap/src/types/screens/mobile' import { sanitizeAddressText, shortenAddress } from 'uniswap/src/utils/addresses' import { ACCOUNT, preloadedSharedState, signerMnemonicAccount } from 'wallet/src/test/fixtures' @@ -35,7 +34,7 @@ describe(AccountHeader, () => { it('renders shortened address within section address without name section', () => { render(, { preloadedState: stateWithoutName }) - const addressSection = screen.getByTestId(TestID.AccountHeaderCopyAddress) + const addressSection = screen.getByTestId('account-header/address-only') const addressText = within(addressSection).queryByText(shortenedAddress) expect(addressText).toBeTruthy() @@ -46,7 +45,7 @@ describe(AccountHeader, () => { jest.spyOn(ExpoClipboard, 'setStringAsync').mockImplementation(setStringAsync) render(, { preloadedState: stateWithoutName }) - const addressSection = screen.getByTestId(TestID.AccountHeaderCopyAddress) + const addressSection = screen.getByTestId('account-header/address-only') fireEvent.press(addressSection, ON_PRESS_EVENT_PAYLOAD) await waitFor(() => { diff --git a/apps/mobile/src/components/accounts/AccountHeader.tsx b/apps/mobile/src/components/accounts/AccountHeader.tsx index a169672f6bf..3c8c3915c0a 100644 --- a/apps/mobile/src/components/accounts/AccountHeader.tsx +++ b/apps/mobile/src/components/accounts/AccountHeader.tsx @@ -1,7 +1,6 @@ import { SharedEventName } from '@uniswap/analytics-events' import React, { useCallback, useEffect } from 'react' -import { useDispatch } from 'react-redux' -import { useAppSelector } from 'src/app/hooks' +import { useAppDispatch, useAppSelector } from 'src/app/hooks' import { navigate } from 'src/app/navigation/rootNavigation' import { openModal } from 'src/features/modals/modalSlice' import { Flex, HapticFeedback, ImpactFeedbackStyle, Text, TouchableArea } from 'ui/src' @@ -12,7 +11,6 @@ import { MobileUserPropertyName, setUserProperty } from 'uniswap/src/features/te import { TestID } from 'uniswap/src/test/fixtures/testIDs' import { MobileScreens } from 'uniswap/src/types/screens/mobile' import { sanitizeAddressText, shortenAddress } from 'uniswap/src/utils/addresses' -import { setClipboard } from 'uniswap/src/utils/clipboard' import { isDevEnv } from 'utilities/src/environment' import { AccountIcon } from 'wallet/src/components/accounts/AccountIcon' import { AnimatedUnitagDisplayName } from 'wallet/src/components/accounts/AnimatedUnitagDisplayName' @@ -22,11 +20,12 @@ import { AccountType } from 'wallet/src/features/wallet/accounts/types' import { useAvatar, useDisplayName } from 'wallet/src/features/wallet/hooks' import { selectActiveAccount, selectActiveAccountAddress } from 'wallet/src/features/wallet/selectors' import { DisplayNameType } from 'wallet/src/features/wallet/types' +import { setClipboard } from 'wallet/src/utils/clipboard' export function AccountHeader(): JSX.Element { const activeAddress = useAppSelector(selectActiveAccountAddress) const account = useAppSelector(selectActiveAccount) - const dispatch = useDispatch() + const dispatch = useAppDispatch() const { avatar } = useAvatar(activeAddress) const displayName = useDisplayName(activeAddress) @@ -84,7 +83,7 @@ export function AccountHeader(): JSX.Element { flexDirection="row" hapticStyle={ImpactFeedbackStyle.Medium} hitSlop={20} - testID={TestID.AccountHeaderAvatar} + testID={TestID.Manage} onLongPress={async (): Promise => { if (isDevEnv()) { await HapticFeedback.selection() @@ -126,7 +125,7 @@ export function AccountHeader(): JSX.Element { diff --git a/apps/mobile/src/components/accounts/__snapshots__/AccountHeader.test.tsx.snap b/apps/mobile/src/components/accounts/__snapshots__/AccountHeader.test.tsx.snap index 8c8e8ede1cc..3c1b670cc09 100644 --- a/apps/mobile/src/components/accounts/__snapshots__/AccountHeader.test.tsx.snap +++ b/apps/mobile/src/components/accounts/__snapshots__/AccountHeader.test.tsx.snap @@ -60,7 +60,7 @@ exports[`AccountHeader renders correctly 1`] = ` ], } } - testID="account-header-avatar" + testID="manage" > void }): JSX.Element { - const dispatch = useDispatch() + const dispatch = useAppDispatch() const { t } = useTranslation() const { fullWidth } = useDeviceDimensions() const colors = useSporeColors() diff --git a/apps/mobile/src/components/buttons/CopyTextButton.test.tsx b/apps/mobile/src/components/buttons/CopyTextButton.test.tsx index 423572ec10a..e7bfd831c60 100644 --- a/apps/mobile/src/components/buttons/CopyTextButton.test.tsx +++ b/apps/mobile/src/components/buttons/CopyTextButton.test.tsx @@ -1,8 +1,8 @@ import { CopyTextButton } from 'src/components/buttons/CopyTextButton' import { act, fireEvent, render } from 'src/test/test-utils' -import { setClipboard } from 'uniswap/src/utils/clipboard' +import { setClipboard } from 'wallet/src/utils/clipboard' -jest.mock('uniswap/src/utils/clipboard') +jest.mock('wallet/src/utils/clipboard') describe(CopyTextButton, () => { beforeEach(() => { diff --git a/apps/mobile/src/components/buttons/CopyTextButton.tsx b/apps/mobile/src/components/buttons/CopyTextButton.tsx index 12c22aa58a6..88c95621e18 100644 --- a/apps/mobile/src/components/buttons/CopyTextButton.tsx +++ b/apps/mobile/src/components/buttons/CopyTextButton.tsx @@ -4,8 +4,8 @@ import { Button, useSporeColors } from 'ui/src' import CheckCircle from 'ui/src/assets/icons/check-circle.svg' import CopySheets from 'ui/src/assets/icons/copy-sheets.svg' import { iconSizes } from 'ui/src/theme' -import { setClipboard } from 'uniswap/src/utils/clipboard' import { useTimeout } from 'utilities/src/time/timing' +import { setClipboard } from 'wallet/src/utils/clipboard' interface Props { copyText?: string diff --git a/apps/mobile/src/components/buttons/LinkButton.test.tsx b/apps/mobile/src/components/buttons/LinkButton.test.tsx index 457bf9e46c3..d7bee3d2088 100644 --- a/apps/mobile/src/components/buttons/LinkButton.test.tsx +++ b/apps/mobile/src/components/buttons/LinkButton.test.tsx @@ -2,7 +2,7 @@ import { LinkButton } from 'src/components/buttons/LinkButton' import { fireEvent, render } from 'src/test/test-utils' import { ON_PRESS_EVENT_PAYLOAD } from 'uniswap/src/test/fixtures' -jest.mock('uniswap/src/utils/linking') +jest.mock('wallet/src/utils/linking') describe(LinkButton, () => { it('renders without error', () => { @@ -38,7 +38,7 @@ describe(LinkButton, () => { const button = getByText('link text') fireEvent.press(button, ON_PRESS_EVENT_PAYLOAD) - expect(require('uniswap/src/utils/linking').openUri).toHaveBeenCalledWith( + expect(require('wallet/src/utils/linking').openUri).toHaveBeenCalledWith( 'https://example.com', openExternalBrowser, isSafeUri, diff --git a/apps/mobile/src/components/buttons/LinkButton.tsx b/apps/mobile/src/components/buttons/LinkButton.tsx index cc6cedd8932..6132dec98ac 100644 --- a/apps/mobile/src/components/buttons/LinkButton.tsx +++ b/apps/mobile/src/components/buttons/LinkButton.tsx @@ -2,7 +2,7 @@ import React, { useMemo } from 'react' import { Flex, FlexProps, Text, TouchableArea, TouchableAreaProps, useSporeColors } from 'ui/src' import ExternalLinkIcon from 'ui/src/assets/icons/external-link.svg' import { TextVariantTokens, iconSizes } from 'ui/src/theme' -import { openUri } from 'uniswap/src/utils/linking' +import { openUri } from 'wallet/src/utils/linking' interface LinkButtonProps extends Omit { label: string diff --git a/apps/mobile/src/components/explore/ExploreSections.tsx b/apps/mobile/src/components/explore/ExploreSections.tsx index 3e59299f77c..d6dae1f4b92 100644 --- a/apps/mobile/src/components/explore/ExploreSections.tsx +++ b/apps/mobile/src/components/explore/ExploreSections.tsx @@ -26,10 +26,10 @@ import { useExploreTokensTabQuery, } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' import { fromGraphQLChain } from 'uniswap/src/features/chains/utils' -import { usePersistedError } from 'uniswap/src/features/dataApi/utils' import { UniverseChainId } from 'uniswap/src/types/chains' import { areAddressesEqual } from 'uniswap/src/utils/addresses' import { buildCurrencyId, buildNativeCurrencyId } from 'uniswap/src/utils/currencyId' +import { usePersistedError } from 'wallet/src/features/dataApi/utils' import { selectHasFavoriteTokens, selectHasWatchedWallets } from 'wallet/src/features/favorites/selectors' import { selectTokensOrderBy } from 'wallet/src/features/wallet/selectors' @@ -191,7 +191,6 @@ export function ExploreSections({ listRef }: ExploreSectionsProps): JSX.Element contentContainerStyle={{ paddingBottom: insets.bottom }} data={showLoading ? undefined : topTokenItems} keyExtractor={tokenKey} - removeClippedSubviews={false} renderItem={renderItem} scrollEventThrottle={16} showsHorizontalScrollIndicator={false} diff --git a/apps/mobile/src/components/explore/FavoriteTokenCard.tsx b/apps/mobile/src/components/explore/FavoriteTokenCard.tsx index 73f3cdfa608..e1ec81dc633 100644 --- a/apps/mobile/src/components/explore/FavoriteTokenCard.tsx +++ b/apps/mobile/src/components/explore/FavoriteTokenCard.tsx @@ -2,7 +2,7 @@ import React, { memo, useCallback } from 'react' import { ViewProps } from 'react-native' import ContextMenu from 'react-native-context-menu-view' import { FadeIn, SharedValue } from 'react-native-reanimated' -import { useDispatch } from 'react-redux' +import { useAppDispatch } from 'src/app/hooks' import { useTokenDetailsNavigation } from 'src/components/TokenDetails/hooks' import RemoveButton from 'src/components/explore/RemoveButton' import { useAnimatedCardDragStyle, useExploreTokenContextMenu } from 'src/components/explore/hooks' @@ -17,13 +17,13 @@ import { TokenLogo } from 'uniswap/src/components/CurrencyLogo/TokenLogo' import { PollingInterval } from 'uniswap/src/constants/misc' import { useFavoriteTokenCardQuery } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' import { fromGraphQLChain } from 'uniswap/src/features/chains/utils' -import { currencyIdToContractInput } from 'uniswap/src/features/dataApi/utils' import { SectionName } from 'uniswap/src/features/telemetry/constants' import { UniverseChainId } from 'uniswap/src/types/chains' import { getSymbolDisplayText } from 'uniswap/src/utils/currency' import { NumberType } from 'utilities/src/format/types' import { RelativeChange } from 'wallet/src/components/text/RelativeChange' import { isNonPollingRequestInFlight } from 'wallet/src/data/utils' +import { currencyIdToContractInput } from 'wallet/src/features/dataApi/utils' import { removeFavoriteToken } from 'wallet/src/features/favorites/slice' import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' @@ -45,7 +45,7 @@ function FavoriteTokenCard({ setIsEditing, ...rest }: FavoriteTokenCardProps): JSX.Element { - const dispatch = useDispatch() + const dispatch = useAppDispatch() const tokenDetailsNavigation = useTokenDetailsNavigation() const { convertFiatAmountFormatted } = useLocalizationContext() diff --git a/apps/mobile/src/components/explore/FavoriteTokensGrid.tsx b/apps/mobile/src/components/explore/FavoriteTokensGrid.tsx index c018560a890..f9e46081ec2 100644 --- a/apps/mobile/src/components/explore/FavoriteTokensGrid.tsx +++ b/apps/mobile/src/components/explore/FavoriteTokensGrid.tsx @@ -1,7 +1,6 @@ import React, { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { FadeIn, useAnimatedStyle, useSharedValue } from 'react-native-reanimated' -import { useDispatch } from 'react-redux' import { useAppSelector } from 'src/app/hooks' import { FavoriteHeaderRow } from 'src/components/explore/FavoriteHeaderRow' import FavoriteTokenCard, { FAVORITE_TOKEN_CARD_LOADER_HEIGHT } from 'src/components/explore/FavoriteTokenCard' @@ -16,6 +15,7 @@ import { Flex } from 'ui/src' import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' import { selectFavoriteTokens } from 'wallet/src/features/favorites/selectors' import { setFavoriteTokens } from 'wallet/src/features/favorites/slice' +import { useAppDispatch } from 'wallet/src/state' const NUM_COLUMNS = 2 const ITEM_FLEX = { flex: 1 / NUM_COLUMNS } @@ -27,7 +27,7 @@ type FavoriteTokensGridProps = AutoScrollProps & { /** Renders the favorite tokens section on the Explore tab */ export function FavoriteTokensGrid({ showLoading, ...rest }: FavoriteTokensGridProps): JSX.Element | null { const { t } = useTranslation() - const dispatch = useDispatch() + const dispatch = useAppDispatch() const [isEditing, setIsEditing] = useState(false) const isTokenDragged = useSharedValue(false) diff --git a/apps/mobile/src/components/explore/FavoriteWalletCard.tsx b/apps/mobile/src/components/explore/FavoriteWalletCard.tsx index 0a248995836..e7299c5cf65 100644 --- a/apps/mobile/src/components/explore/FavoriteWalletCard.tsx +++ b/apps/mobile/src/components/explore/FavoriteWalletCard.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next' import { ViewProps } from 'react-native' import ContextMenu from 'react-native-context-menu-view' import { SharedValue } from 'react-native-reanimated' -import { useDispatch } from 'react-redux' +import { useAppDispatch } from 'src/app/hooks' import { useEagerExternalProfileNavigation } from 'src/app/navigation/hooks' import RemoveButton from 'src/components/explore/RemoveButton' import { useAnimatedCardDragStyle } from 'src/components/explore/hooks' @@ -35,7 +35,7 @@ function FavoriteWalletCard({ ...rest }: FavoriteWalletCardProps): JSX.Element { const { t } = useTranslation() - const dispatch = useDispatch() + const dispatch = useAppDispatch() const { preload, navigate } = useEagerExternalProfileNavigation() const displayName = useDisplayName(address) diff --git a/apps/mobile/src/components/explore/FavoriteWalletsGrid.tsx b/apps/mobile/src/components/explore/FavoriteWalletsGrid.tsx index a72d91eb5ae..314cd6b0d87 100644 --- a/apps/mobile/src/components/explore/FavoriteWalletsGrid.tsx +++ b/apps/mobile/src/components/explore/FavoriteWalletsGrid.tsx @@ -1,7 +1,6 @@ import { default as React, useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { FadeIn, useAnimatedStyle, useSharedValue } from 'react-native-reanimated' -import { useDispatch } from 'react-redux' import { useAppSelector } from 'src/app/hooks' import { FavoriteHeaderRow } from 'src/components/explore/FavoriteHeaderRow' import FavoriteWalletCard from 'src/components/explore/FavoriteWalletCard' @@ -16,6 +15,7 @@ import { Flex } from 'ui/src' import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' import { selectWatchedAddressSet } from 'wallet/src/features/favorites/selectors' import { setFavoriteWallets } from 'wallet/src/features/favorites/slice' +import { useAppDispatch } from 'wallet/src/state' const NUM_COLUMNS = 2 const ITEM_FLEX = { flex: 1 / NUM_COLUMNS } @@ -27,7 +27,7 @@ type FavoriteWalletsGridProps = AutoScrollProps & { /** Renders the favorite wallets section on the Explore tab */ export function FavoriteWalletsGrid({ showLoading, ...rest }: FavoriteWalletsGridProps): JSX.Element { const { t } = useTranslation() - const dispatch = useDispatch() + const dispatch = useAppDispatch() const [isEditing, setIsEditing] = useState(false) const isTokenDragged = useSharedValue(false) diff --git a/apps/mobile/src/components/explore/RemoveButton.tsx b/apps/mobile/src/components/explore/RemoveButton.tsx index af9f6248873..d03f9f543ac 100644 --- a/apps/mobile/src/components/explore/RemoveButton.tsx +++ b/apps/mobile/src/components/explore/RemoveButton.tsx @@ -17,7 +17,6 @@ export default function RemoveButton({ visible = true, ...rest }: RemoveButtonPr alignItems="center" backgroundColor="$neutral3" borderRadius="$roundedFull" - disabled={!visible} height={imageSizes.image24} justifyContent="center" style={animatedVisibilityStyle} diff --git a/apps/mobile/src/components/explore/SortButton.tsx b/apps/mobile/src/components/explore/SortButton.tsx index 1c6bfae4674..e0d00720fe6 100644 --- a/apps/mobile/src/components/explore/SortButton.tsx +++ b/apps/mobile/src/components/explore/SortButton.tsx @@ -1,7 +1,7 @@ import React, { memo, useMemo } from 'react' import { useTranslation } from 'react-i18next' import ContextMenu from 'react-native-context-menu-view' -import { useDispatch } from 'react-redux' +import { useAppDispatch } from 'src/app/hooks' import { getTokensOrderByMenuLabel, getTokensOrderBySelectedLabel } from 'src/features/explore/utils' import { disableOnPress } from 'src/utils/disableOnPress' import { Flex, Text, TouchableArea, useIsDarkMode } from 'ui/src' @@ -19,7 +19,7 @@ interface FilterGroupProps { function _SortButton({ orderBy }: FilterGroupProps): JSX.Element { const isDarkMode = useIsDarkMode() - const dispatch = useDispatch() + const dispatch = useAppDispatch() const { t } = useTranslation() const menuActions = useMemo(() => { diff --git a/apps/mobile/src/components/explore/__snapshots__/FavoriteWalletCard.test.tsx.snap b/apps/mobile/src/components/explore/__snapshots__/FavoriteWalletCard.test.tsx.snap index 1062aedd8ca..62aca13bb0b 100644 --- a/apps/mobile/src/components/explore/__snapshots__/FavoriteWalletCard.test.tsx.snap +++ b/apps/mobile/src/components/explore/__snapshots__/FavoriteWalletCard.test.tsx.snap @@ -232,16 +232,11 @@ exports[`FavoriteWalletCard renders without error 1`] = ` - diff --git a/apps/mobile/src/components/explore/hooks.test.ts b/apps/mobile/src/components/explore/hooks.test.ts index 96e42dc0d5f..20c366e7d3f 100644 --- a/apps/mobile/src/components/explore/hooks.test.ts +++ b/apps/mobile/src/components/explore/hooks.test.ts @@ -5,9 +5,9 @@ import { useExploreTokenContextMenu } from 'src/components/explore/hooks' import { renderHookWithProviders } from 'src/test/render' import { Resolvers } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' import { SectionName } from 'uniswap/src/features/telemetry/constants' -import { CurrencyField } from 'uniswap/src/features/transactions/transactionState/types' import { FavoritesState } from 'wallet/src/features/favorites/slice' -import { SAMPLE_SEED_ADDRESS_1 } from 'wallet/src/test/fixtures' +import { CurrencyField } from 'wallet/src/features/transactions/transactionState/types' +import { SAMPLE_SEED_ADDRESS_1 } from 'wallet/src/test/fixtures/constants' import { cleanup } from 'wallet/src/test/test-utils' const tokenId = SAMPLE_SEED_ADDRESS_1 @@ -30,7 +30,10 @@ describe(useExploreTokenContextMenu, () => { describe('editing favorite tokens', () => { it('renders proper context menu items when onEditFavorites is not provided', async () => { - const { result } = renderHookWithProviders(() => useExploreTokenContextMenu(tokenMenuParams), { resolvers }) + const { result } = renderHookWithProviders( + () => useExploreTokenContextMenu(tokenMenuParams), + { resolvers } + ) expect(result.current.menuActions).toEqual([ expect.objectContaining({ @@ -57,7 +60,7 @@ describe(useExploreTokenContextMenu, () => { const onEditFavorites = jest.fn() const { result } = renderHookWithProviders( () => useExploreTokenContextMenu({ ...tokenMenuParams, onEditFavorites }), - { resolvers }, + { resolvers } ) expect(result.current.menuActions).toEqual([ @@ -85,11 +88,11 @@ describe(useExploreTokenContextMenu, () => { const onEditFavorites = jest.fn() const { result } = renderHookWithProviders( () => useExploreTokenContextMenu({ ...tokenMenuParams, onEditFavorites }), - { resolvers }, + { resolvers } ) const editFavoritesActionIndex = result.current.menuActions.findIndex( - (action: ContextMenuAction) => action.title === 'Edit favorites', + (action: ContextMenuAction) => action.title === 'Edit favorites' ) result.current.onContextMenuPress({ nativeEvent: { index: editFavoritesActionIndex }, @@ -102,12 +105,15 @@ describe(useExploreTokenContextMenu, () => { describe('adding / removing favorite tokens', () => { it('renders proper context menu items when token is favorited', async () => { - const { result } = renderHookWithProviders(() => useExploreTokenContextMenu(tokenMenuParams), { - preloadedState: { - favorites: { tokens: [tokenMenuParams.currencyId.toLowerCase()] } as FavoritesState, - }, - resolvers, - }) + const { result } = renderHookWithProviders( + () => useExploreTokenContextMenu(tokenMenuParams), + { + preloadedState: { + favorites: { tokens: [tokenMenuParams.currencyId.toLowerCase()] } as FavoritesState, + }, + resolvers, + } + ) expect(result.current.menuActions).toEqual([ expect.objectContaining({ @@ -132,13 +138,13 @@ describe(useExploreTokenContextMenu, () => { it("dispatches add to favorites redux action when 'Favorite token' is pressed", async () => { const store = mockStore({ favorites: { tokens: [] }, appearance: { theme: 'system' } }) - const { result } = renderHookWithProviders(() => useExploreTokenContextMenu(tokenMenuParams), { - resolvers, - store, - }) + const { result } = renderHookWithProviders( + () => useExploreTokenContextMenu(tokenMenuParams), + { resolvers, store } + ) const favoriteTokenActionIndex = result.current.menuActions.findIndex( - (action: ContextMenuAction) => action.title === 'Favorite token', + (action: ContextMenuAction) => action.title === 'Favorite token' ) result.current.onContextMenuPress({ nativeEvent: { index: favoriteTokenActionIndex }, @@ -159,13 +165,13 @@ describe(useExploreTokenContextMenu, () => { favorites: { tokens: [tokenMenuParams.currencyId.toLowerCase()] }, appearance: { theme: 'system' }, }) - const { result } = renderHookWithProviders(() => useExploreTokenContextMenu(tokenMenuParams), { - resolvers, - store, - }) + const { result } = renderHookWithProviders( + () => useExploreTokenContextMenu(tokenMenuParams), + { resolvers, store } + ) const removeFavoriteTokenActionIndex = result.current.menuActions.findIndex( - (action: ContextMenuAction) => action.title === 'Remove favorite', + (action: ContextMenuAction) => action.title === 'Remove favorite' ) result.current.onContextMenuPress({ nativeEvent: { index: removeFavoriteTokenActionIndex }, @@ -192,7 +198,9 @@ describe(useExploreTokenContextMenu, () => { resolvers, }) - const swapActionIndex = result.current.menuActions.findIndex((action: ContextMenuAction) => action.title === 'Swap') + const swapActionIndex = result.current.menuActions.findIndex( + (action: ContextMenuAction) => action.title === 'Swap' + ) result.current.onContextMenuPress({ nativeEvent: { index: swapActionIndex }, } as NativeSyntheticEvent) @@ -227,7 +235,7 @@ describe(useExploreTokenContextMenu, () => { jest.spyOn(Share, 'share') const shareActionIndex = result.current.menuActions.findIndex( - (action: ContextMenuAction) => action.title === 'Share', + (action: ContextMenuAction) => action.title === 'Share' ) result.current.onContextMenuPress({ nativeEvent: { index: shareActionIndex }, diff --git a/apps/mobile/src/components/explore/hooks.ts b/apps/mobile/src/components/explore/hooks.ts index e422a93953f..16986f14310 100644 --- a/apps/mobile/src/components/explore/hooks.ts +++ b/apps/mobile/src/components/explore/hooks.ts @@ -4,18 +4,18 @@ import { useTranslation } from 'react-i18next' import { NativeSyntheticEvent } from 'react-native' import { ContextMenuAction, ContextMenuOnPressNativeEvent } from 'react-native-context-menu-view' import { SharedValue, StyleProps, interpolate, useAnimatedStyle } from 'react-native-reanimated' -import { useDispatch } from 'react-redux' import { useSelectHasTokenFavorited, useToggleFavoriteCallback } from 'src/features/favorites/hooks' import { openModal } from 'src/features/modals/modalSlice' -import { AssetType } from 'uniswap/src/entities/assets' import { ElementName, ModalName, SectionNameType } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' -import { CurrencyField, TransactionState } from 'uniswap/src/features/transactions/transactionState/types' import { WalletChainId } from 'uniswap/src/types/chains' import { CurrencyId } from 'uniswap/src/types/currency' import { currencyIdToAddress } from 'uniswap/src/utils/currencyId' import { ScannerModalState } from 'wallet/src/components/QRCodeScanner/constants' import { useWalletNavigation } from 'wallet/src/contexts/WalletNavigationContext' +import { AssetType } from 'wallet/src/entities/assets' +import { CurrencyField, TransactionState } from 'wallet/src/features/transactions/transactionState/types' +import { useAppDispatch } from 'wallet/src/state' interface TokenMenuParams { currencyId: CurrencyId @@ -37,7 +37,7 @@ export function useExploreTokenContextMenu({ } { const { t } = useTranslation() const isFavorited = useSelectHasTokenFavorited(currencyId) - const dispatch = useDispatch() + const dispatch = useAppDispatch() const { handleShareToken } = useWalletNavigation() diff --git a/apps/mobile/src/components/explore/search/SearchEmptySection.tsx b/apps/mobile/src/components/explore/search/SearchEmptySection.tsx index 35874264ebe..4972dccc2fb 100644 --- a/apps/mobile/src/components/explore/search/SearchEmptySection.tsx +++ b/apps/mobile/src/components/explore/search/SearchEmptySection.tsx @@ -2,8 +2,7 @@ import React from 'react' import { useTranslation } from 'react-i18next' import { FlatList } from 'react-native' import { FadeIn, FadeOut } from 'react-native-reanimated' -import { useDispatch } from 'react-redux' -import { useAppSelector } from 'src/app/hooks' +import { useAppDispatch, useAppSelector } from 'src/app/hooks' import { SearchPopularNFTCollections } from 'src/components/explore/search/SearchPopularNFTCollections' import { SearchPopularTokens } from 'src/components/explore/search/SearchPopularTokens' import { renderSearchItem } from 'src/components/explore/search/SearchResultsSection' @@ -13,8 +12,7 @@ import ClockIcon from 'ui/src/assets/icons/clock.svg' import { TrendUp } from 'ui/src/components/icons' import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' import { iconSizes } from 'ui/src/theme' -import { SearchResultType } from 'uniswap/src/features/search/SearchResult' -import { WalletSearchResult } from 'wallet/src/features/search/SearchResult' +import { SearchResultType, WalletSearchResult } from 'wallet/src/features/search/SearchResult' import { clearSearchHistory } from 'wallet/src/features/search/searchHistorySlice' import { selectSearchHistory } from 'wallet/src/features/search/selectSearchHistory' @@ -35,7 +33,7 @@ export const SUGGESTED_WALLETS: WalletSearchResult[] = [ export function SearchEmptySection(): JSX.Element { const { t } = useTranslation() - const dispatch = useDispatch() + const dispatch = useAppDispatch() const searchHistory = useAppSelector(selectSearchHistory) const onPressClearSearchHistory = (): void => { diff --git a/apps/mobile/src/components/explore/search/SearchPopularNFTCollections.tsx b/apps/mobile/src/components/explore/search/SearchPopularNFTCollections.tsx index 4a78b2c2531..b7edf4c754e 100644 --- a/apps/mobile/src/components/explore/search/SearchPopularNFTCollections.tsx +++ b/apps/mobile/src/components/explore/search/SearchPopularNFTCollections.tsx @@ -2,10 +2,9 @@ import React, { useMemo } from 'react' import { FlatList, ListRenderItemInfo } from 'react-native' 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 { Inset, Loader } from 'ui/src' import { useSearchPopularNftCollectionsQuery } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' -import { SearchResultType } from 'uniswap/src/features/search/SearchResult' -import { NFTCollectionSearchResult } from 'wallet/src/features/search/SearchResult' +import { NFTCollectionSearchResult, SearchResultType } from 'wallet/src/features/search/SearchResult' function isNFTCollectionSearchResult(result: NFTCollectionSearchResult | null): result is NFTCollectionSearchResult { return (result as NFTCollectionSearchResult).type === SearchResultType.NFTCollection @@ -26,9 +25,9 @@ export function SearchPopularNFTCollections(): JSX.Element { if (loading) { return ( - + - + ) } diff --git a/apps/mobile/src/components/explore/search/SearchPopularTokens.tsx b/apps/mobile/src/components/explore/search/SearchPopularTokens.tsx index 58ba3f57a92..5b4b60f54af 100644 --- a/apps/mobile/src/components/explore/search/SearchPopularTokens.tsx +++ b/apps/mobile/src/components/explore/search/SearchPopularTokens.tsx @@ -2,10 +2,9 @@ import React, { useMemo } from 'react' import { FlatList, ListRenderItemInfo } from 'react-native' import { SearchTokenItem } from 'src/components/explore/search/items/SearchTokenItem' import { getSearchResultId } from 'src/components/explore/search/utils' -import { Flex, Loader } from 'ui/src' +import { Inset, Loader } from 'ui/src' import { fromGraphQLChain } from 'uniswap/src/features/chains/utils' -import { SearchResultType } from 'uniswap/src/features/search/SearchResult' -import { TokenSearchResult } from 'wallet/src/features/search/SearchResult' +import { SearchResultType, TokenSearchResult } from 'wallet/src/features/search/SearchResult' import { TopToken, usePopularTokens } from 'wallet/src/features/tokens/hooks' function gqlTokenToTokenSearchResult(token: Maybe): TokenSearchResult | null { @@ -40,9 +39,9 @@ export function SearchPopularTokens(): JSX.Element { if (loading) { return ( - + - + ) } diff --git a/apps/mobile/src/components/explore/search/SearchResultsLoader.tsx b/apps/mobile/src/components/explore/search/SearchResultsLoader.tsx index 544f856694e..b69014155da 100644 --- a/apps/mobile/src/components/explore/search/SearchResultsLoader.tsx +++ b/apps/mobile/src/components/explore/search/SearchResultsLoader.tsx @@ -15,7 +15,7 @@ export const SearchResultsLoader = (): JSX.Element => { icon={} title={t('explore.search.section.tokens')} /> - + @@ -24,7 +24,7 @@ export const SearchResultsLoader = (): JSX.Element => { icon={} title={t('explore.search.section.nft')} /> - + @@ -33,7 +33,7 @@ export const SearchResultsLoader = (): JSX.Element => { icon={} title={t('explore.search.section.wallets')} /> - + diff --git a/apps/mobile/src/components/explore/search/SearchResultsSection.tsx b/apps/mobile/src/components/explore/search/SearchResultsSection.tsx index 506a85991af..dd8a3eb1aec 100644 --- a/apps/mobile/src/components/explore/search/SearchResultsSection.tsx +++ b/apps/mobile/src/components/explore/search/SearchResultsSection.tsx @@ -24,13 +24,12 @@ import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' import { BaseCard } from 'uniswap/src/components/BaseCard/BaseCard' import { UNIVERSE_CHAIN_INFO } from 'uniswap/src/constants/chains' import { useExploreSearchQuery } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' -import { SearchContext } from 'uniswap/src/features/search/SearchContext' -import { SearchResultType } from 'uniswap/src/features/search/SearchResult' import i18n from 'uniswap/src/i18n/i18n' import { UniverseChainId } from 'uniswap/src/types/chains' import { getValidAddress } from 'uniswap/src/utils/addresses' import { logger } from 'utilities/src/logger/logger' -import { NFTCollectionSearchResult, TokenSearchResult } from 'wallet/src/features/search/SearchResult' +import { SearchContext } from 'wallet/src/features/search/SearchContext' +import { NFTCollectionSearchResult, SearchResultType, TokenSearchResult } from 'wallet/src/features/search/SearchResult' const ICON_SIZE = '$icon.24' const ICON_COLOR = '$neutral2' diff --git a/apps/mobile/src/components/explore/search/SearchSectionHeader.tsx b/apps/mobile/src/components/explore/search/SearchSectionHeader.tsx index 6f537ca6e33..1952d47cca1 100644 --- a/apps/mobile/src/components/explore/search/SearchSectionHeader.tsx +++ b/apps/mobile/src/components/explore/search/SearchSectionHeader.tsx @@ -8,7 +8,7 @@ interface SectionHeaderTextProps { export const SectionHeaderText = ({ title, icon, ...rest }: SectionHeaderTextProps & TextProps): JSX.Element => { return ( - + {icon && icon} {title} diff --git a/apps/mobile/src/components/explore/search/hooks.ts b/apps/mobile/src/components/explore/search/hooks.ts index 46628095830..e66cad8c955 100644 --- a/apps/mobile/src/components/explore/search/hooks.ts +++ b/apps/mobile/src/components/explore/search/hooks.ts @@ -1,10 +1,9 @@ import { useMemo } from 'react' -import { SearchResultType } from 'uniswap/src/features/search/SearchResult' import { useUnitagByAddress, useUnitagByName } from 'uniswap/src/features/unitags/hooks' import { UniverseChainId } from 'uniswap/src/types/chains' import { getValidAddress } from 'uniswap/src/utils/addresses' import { useENS } from 'wallet/src/features/ens/useENS' -import { WalletSearchResult } from 'wallet/src/features/search/SearchResult' +import { SearchResultType, WalletSearchResult } from 'wallet/src/features/search/SearchResult' import { useIsSmartContractAddress } from 'wallet/src/features/transactions/transfer/hooks/useIsSmartContractAddress' // eslint-disable-next-line complexity diff --git a/apps/mobile/src/components/explore/search/items/SearchENSAddressItem.tsx b/apps/mobile/src/components/explore/search/items/SearchENSAddressItem.tsx index b96593250ea..4ebc857a132 100644 --- a/apps/mobile/src/components/explore/search/items/SearchENSAddressItem.tsx +++ b/apps/mobile/src/components/explore/search/items/SearchENSAddressItem.tsx @@ -3,11 +3,11 @@ import { useTranslation } from 'react-i18next' import { SearchWalletItemBase } from 'src/components/explore/search/items/SearchWalletItemBase' import { Flex, Text } from 'ui/src' import { imageSizes } from 'ui/src/theme' -import { SearchContext } from 'uniswap/src/features/search/SearchContext' import { sanitizeAddressText, shortenAddress } from 'uniswap/src/utils/addresses' import { AccountIcon } from 'wallet/src/components/accounts/AccountIcon' import { useENSAvatar, useENSName } from 'wallet/src/features/ens/api' import { getCompletedENSName } from 'wallet/src/features/ens/useENS' +import { SearchContext } from 'wallet/src/features/search/SearchContext' import { ENSAddressSearchResult } from 'wallet/src/features/search/SearchResult' type SearchENSAddressItemProps = { @@ -47,7 +47,7 @@ export function SearchENSAddressItem({ searchResult, searchContext }: SearchENSA return ( - + diff --git a/apps/mobile/src/components/explore/search/items/SearchEtherscanItem.tsx b/apps/mobile/src/components/explore/search/items/SearchEtherscanItem.tsx index 28b155634dc..abfe50bed48 100644 --- a/apps/mobile/src/components/explore/search/items/SearchEtherscanItem.tsx +++ b/apps/mobile/src/components/explore/search/items/SearchEtherscanItem.tsx @@ -1,16 +1,15 @@ import { default as React } from 'react' -import { useDispatch } from 'react-redux' +import { useAppDispatch } from 'src/app/hooks' import { getBlockExplorerIcon } from 'src/components/icons/BlockExplorerIcon' import { Flex, ImpactFeedbackStyle, Text, TouchableArea, useSporeColors } from 'ui/src' import { iconSizes } from 'ui/src/theme' import { TestID } from 'uniswap/src/test/fixtures/testIDs' import { UniverseChainId } from 'uniswap/src/types/chains' import { shortenAddress } from 'uniswap/src/utils/addresses' -import { openUri } from 'uniswap/src/utils/linking' import { Arrow } from 'wallet/src/components/icons/Arrow' import { EtherscanSearchResult } from 'wallet/src/features/search/SearchResult' import { addToSearchHistory } from 'wallet/src/features/search/searchHistorySlice' -import { ExplorerDataType, getExplorerLink } from 'wallet/src/utils/linking' +import { ExplorerDataType, getExplorerLink, openUri } from 'wallet/src/utils/linking' type SearchEtherscanItemProps = { etherscanResult: EtherscanSearchResult @@ -18,7 +17,7 @@ type SearchEtherscanItemProps = { export function SearchEtherscanItem({ etherscanResult }: SearchEtherscanItemProps): JSX.Element { const colors = useSporeColors() - const dispatch = useDispatch() + const dispatch = useAppDispatch() const { address } = etherscanResult @@ -41,7 +40,7 @@ export function SearchEtherscanItem({ etherscanResult }: SearchEtherscanItemProp testID={TestID.SearchEtherscanItem} onPress={onPressViewEtherscan} > - + {shortenAddress(address)} diff --git a/apps/mobile/src/components/explore/search/items/SearchNFTCollectionItem.tsx b/apps/mobile/src/components/explore/search/items/SearchNFTCollectionItem.tsx index 8361107aefb..9ed4f71654b 100644 --- a/apps/mobile/src/components/explore/search/items/SearchNFTCollectionItem.tsx +++ b/apps/mobile/src/components/explore/search/items/SearchNFTCollectionItem.tsx @@ -1,17 +1,16 @@ import { default as React } from 'react' -import { useDispatch } from 'react-redux' +import { useAppDispatch } from 'src/app/hooks' import { useAppStackNavigation } from 'src/app/navigation/types' import { Flex, ImpactFeedbackStyle, Text, TouchableArea } from 'ui/src' import { Verified } from 'ui/src/components/icons' import { iconSizes } from 'ui/src/theme' -import { SearchContext } from 'uniswap/src/features/search/SearchContext' -import { SearchResultType } from 'uniswap/src/features/search/SearchResult' 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' -import { NFTCollectionSearchResult } from 'wallet/src/features/search/SearchResult' +import { SearchContext } from 'wallet/src/features/search/SearchContext' +import { NFTCollectionSearchResult, SearchResultType } from 'wallet/src/features/search/SearchResult' import { addToSearchHistory } from 'wallet/src/features/search/searchHistorySlice' type NFTCollectionItemProps = { @@ -21,7 +20,7 @@ type NFTCollectionItemProps = { export function SearchNFTCollectionItem({ collection, searchContext }: NFTCollectionItemProps): JSX.Element { const { name, address, chainId, isVerified, imageUrl } = collection - const dispatch = useDispatch() + const dispatch = useAppDispatch() const navigation = useAppStackNavigation() const onPress = (): void => { @@ -63,7 +62,7 @@ export function SearchNFTCollectionItem({ collection, searchContext }: NFTCollec testID={TestID.SearchNFTCollectionItem} onPress={onPress} > - + - + diff --git a/apps/mobile/src/components/explore/search/items/SearchUnitagItem.tsx b/apps/mobile/src/components/explore/search/items/SearchUnitagItem.tsx index d8331ff4d13..3ec3d1ed0ea 100644 --- a/apps/mobile/src/components/explore/search/items/SearchUnitagItem.tsx +++ b/apps/mobile/src/components/explore/search/items/SearchUnitagItem.tsx @@ -2,10 +2,10 @@ import React from 'react' import { SearchWalletItemBase } from 'src/components/explore/search/items/SearchWalletItemBase' import { Flex, Text } from 'ui/src' import { imageSizes } from 'ui/src/theme' -import { SearchContext } from 'uniswap/src/features/search/SearchContext' import { sanitizeAddressText, shortenAddress } from 'uniswap/src/utils/addresses' import { AccountIcon } from 'wallet/src/components/accounts/AccountIcon' import { DisplayNameText } from 'wallet/src/components/accounts/DisplayNameText' +import { SearchContext } from 'wallet/src/features/search/SearchContext' import { UnitagSearchResult } from 'wallet/src/features/search/SearchResult' import { useAvatar } from 'wallet/src/features/wallet/hooks' import { DisplayNameType } from 'wallet/src/features/wallet/types' @@ -23,7 +23,7 @@ export function SearchUnitagItem({ searchResult, searchContext }: SearchUnitagIt return ( - + diff --git a/apps/mobile/src/components/explore/search/items/SearchWalletByAddressItem.tsx b/apps/mobile/src/components/explore/search/items/SearchWalletByAddressItem.tsx index dceb54b26c1..ae682cd32f1 100644 --- a/apps/mobile/src/components/explore/search/items/SearchWalletByAddressItem.tsx +++ b/apps/mobile/src/components/explore/search/items/SearchWalletByAddressItem.tsx @@ -2,10 +2,10 @@ import React from 'react' import { SearchWalletItemBase } from 'src/components/explore/search/items/SearchWalletItemBase' import { Flex, Text } from 'ui/src' import { imageSizes } from 'ui/src/theme' -import { SearchContext } from 'uniswap/src/features/search/SearchContext' import { sanitizeAddressText, shortenAddress } from 'uniswap/src/utils/addresses' import { AccountIcon } from 'wallet/src/components/accounts/AccountIcon' import { useENSAvatar, useENSName } from 'wallet/src/features/ens/api' +import { SearchContext } from 'wallet/src/features/search/SearchContext' import { WalletByAddressSearchResult } from 'wallet/src/features/search/SearchResult' type SearchWalletByAddressItemProps = { @@ -24,7 +24,7 @@ export function SearchWalletByAddressItem({ return ( - + diff --git a/apps/mobile/src/components/explore/search/items/SearchWalletItemBase.tsx b/apps/mobile/src/components/explore/search/items/SearchWalletItemBase.tsx index ead2cf54655..88c599857c8 100644 --- a/apps/mobile/src/components/explore/search/items/SearchWalletItemBase.tsx +++ b/apps/mobile/src/components/explore/search/items/SearchWalletItemBase.tsx @@ -1,18 +1,16 @@ import React, { PropsWithChildren, useMemo } from 'react' import { useTranslation } from 'react-i18next' import ContextMenu from 'react-native-context-menu-view' -import { useDispatch } from 'react-redux' -import { useAppSelector } from 'src/app/hooks' +import { useAppDispatch, useAppSelector } from 'src/app/hooks' import { useEagerExternalProfileNavigation } from 'src/app/navigation/hooks' import { useToggleWatchedWalletCallback } from 'src/features/favorites/hooks' import { disableOnPress } from 'src/utils/disableOnPress' import { ImpactFeedbackStyle, TouchableArea } from 'ui/src' -import { SearchContext } from 'uniswap/src/features/search/SearchContext' -import { SearchResultType } from 'uniswap/src/features/search/SearchResult' import { MobileEventName } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import { selectWatchedAddressSet } from 'wallet/src/features/favorites/selectors' -import { WalletSearchResult, extractDomain } from 'wallet/src/features/search/SearchResult' +import { SearchContext } from 'wallet/src/features/search/SearchContext' +import { SearchResultType, WalletSearchResult, extractDomain } from 'wallet/src/features/search/SearchResult' import { addToSearchHistory } from 'wallet/src/features/search/searchHistorySlice' type SearchWalletItemBaseProps = { @@ -26,7 +24,7 @@ export function SearchWalletItemBase({ searchContext, }: PropsWithChildren): JSX.Element { const { t } = useTranslation() - const dispatch = useDispatch() + const dispatch = useAppDispatch() const { preload, navigate } = useEagerExternalProfileNavigation() const { address, type } = searchResult const isFavorited = useAppSelector(selectWatchedAddressSet).has(address) diff --git a/apps/mobile/src/components/explore/search/utils.test.ts b/apps/mobile/src/components/explore/search/utils.test.ts index 675f5456f15..5d5dbaa4937 100644 --- a/apps/mobile/src/components/explore/search/utils.test.ts +++ b/apps/mobile/src/components/explore/search/utils.test.ts @@ -4,9 +4,12 @@ import { formatTokenSearchResults, gqlNFTToNFTCollectionSearchResult, } from 'src/components/explore/search/utils' -import { Chain, ExploreSearchQuery } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' +import { + Chain, + ExploreSearchQuery, +} from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' import { fromGraphQLChain } from 'uniswap/src/features/chains/utils' -import { SearchResultType } from 'uniswap/src/features/search/SearchResult' +import { SearchResultType } from 'wallet/src/features/search/SearchResult' import { amount, ethToken, @@ -96,7 +99,9 @@ describe(formatTokenSearchResults, () => { 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: undefined })).toEqual( + null + ) expect(gqlNFTToNFTCollectionSearchResult({ ...collection, nftContracts: [] })).toEqual(null) }) @@ -120,7 +125,10 @@ describe(formatTokenSearchResults, () => { 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 }) }], + edges: [ + ...topNFTCollections.map((nft) => ({ node: nft })), + { node: nftCollection({ name: undefined }) }, + ], } const result = formatNFTCollectionSearchResults(nftSearchResult) diff --git a/apps/mobile/src/components/explore/search/utils.ts b/apps/mobile/src/components/explore/search/utils.ts index eb7760d6a6f..fe79efdfb4d 100644 --- a/apps/mobile/src/components/explore/search/utils.ts +++ b/apps/mobile/src/components/explore/search/utils.ts @@ -2,8 +2,7 @@ import { SEARCH_RESULT_HEADER_KEY } from 'src/components/explore/search/constant import { SearchResultOrHeader } from 'src/components/explore/search/types' import { Chain, ExploreSearchQuery } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' import { fromGraphQLChain } from 'uniswap/src/features/chains/utils' -import { SearchResultType } from 'uniswap/src/features/search/SearchResult' -import { NFTCollectionSearchResult, TokenSearchResult } from 'wallet/src/features/search/SearchResult' +import { NFTCollectionSearchResult, SearchResultType, TokenSearchResult } from 'wallet/src/features/search/SearchResult' import { searchResultId } from 'wallet/src/features/search/searchHistorySlice' const MAX_TOKEN_RESULTS_COUNT = 4 diff --git a/apps/mobile/src/components/forceUpgrade/ForceUpgradeModal.tsx b/apps/mobile/src/components/forceUpgrade/ForceUpgradeModal.tsx index 8a1f069873e..8a25880442c 100644 --- a/apps/mobile/src/components/forceUpgrade/ForceUpgradeModal.tsx +++ b/apps/mobile/src/components/forceUpgrade/ForceUpgradeModal.tsx @@ -6,23 +6,19 @@ import { APP_STORE_LINK } from 'src/constants/urls' import { UpgradeStatus } from 'src/features/forceUpgrade/types' import { Flex, Text, TouchableArea, useSporeColors } from 'ui/src' import { BottomSheetModal } from 'uniswap/src/components/modals/BottomSheetModal' -import { DynamicConfigs, ForceUpgradeConfigKey } from 'uniswap/src/features/gating/configs' -import { useDynamicConfigValue } from 'uniswap/src/features/gating/hooks' +import { DynamicConfigs } from 'uniswap/src/features/gating/configs' +import { useDynamicConfig } from 'uniswap/src/features/gating/hooks' import { ModalName } from 'uniswap/src/features/telemetry/constants' -import { openUri } from 'uniswap/src/utils/linking' import { WarningModal } from 'wallet/src/components/modals/WarningModal/WarningModal' import { WarningSeverity } from 'wallet/src/features/transactions/WarningModal/types' import { SignerMnemonicAccount } from 'wallet/src/features/wallet/accounts/types' import { useSignerAccounts } from 'wallet/src/features/wallet/hooks' +import { openUri } from 'wallet/src/utils/linking' export function ForceUpgradeModal(): JSX.Element { const { t } = useTranslation() const colors = useSporeColors() - const forceUpgradeStatusString = useDynamicConfigValue( - DynamicConfigs.MobileForceUpgrade, - ForceUpgradeConfigKey.Status, - '' as string, - ) + const forceUpgradeConfig = useDynamicConfig(DynamicConfigs.MobileForceUpgrade) const [isVisible, setIsVisible] = useState(false) const [upgradeStatus, setUpgradeStatus] = useState(UpgradeStatus.NotRequired) @@ -34,15 +30,17 @@ export function ForceUpgradeModal(): JSX.Element { const [showSeedPhrase, setShowSeedPhrase] = useState(false) useEffect(() => { + const statusString = forceUpgradeConfig.getValue('status')?.toString() + let status = UpgradeStatus.NotRequired - if (forceUpgradeStatusString === 'recommended') { + if (statusString === 'recommended') { status = UpgradeStatus.Recommended - } else if (forceUpgradeStatusString === 'required') { + } else if (statusString === 'required') { status = UpgradeStatus.Required } setUpgradeStatus(status) setIsVisible(status !== UpgradeStatus.NotRequired) - }, [forceUpgradeStatusString]) + }, [forceUpgradeConfig]) const onPressConfirm = async (): Promise => { await openUri(APP_STORE_LINK, /*openExternalBrowser=*/ true, /*isSafeUri=*/ true) diff --git a/apps/mobile/src/components/home/ActivityTab.tsx b/apps/mobile/src/components/home/ActivityTab.tsx index 937f792a81e..f135a6a5e04 100644 --- a/apps/mobile/src/components/home/ActivityTab.tsx +++ b/apps/mobile/src/components/home/ActivityTab.tsx @@ -1,7 +1,7 @@ import { ForwardedRef, forwardRef, memo, useMemo } from 'react' import { FlatList, RefreshControl } from 'react-native' import Animated from 'react-native-reanimated' -import { useDispatch } from 'react-redux' +import { useAppDispatch } from 'src/app/hooks' import { useAdaptiveFooter } from 'src/components/home/hooks' import { AnimatedBottomSheetFlatList, AnimatedFlatList } from 'src/components/layout/AnimatedFlatList' import { TAB_BAR_HEIGHT, TabProps } from 'src/components/layout/TabHelpers' @@ -33,7 +33,7 @@ export const ActivityTab = memo( }, ref, ) { - const dispatch = useDispatch() + const dispatch = useAppDispatch() const colors = useSporeColors() const insets = useDeviceInsets() diff --git a/apps/mobile/src/components/home/FeedTab.tsx b/apps/mobile/src/components/home/FeedTab.tsx index a59a7eb67b9..a4084f50641 100644 --- a/apps/mobile/src/components/home/FeedTab.tsx +++ b/apps/mobile/src/components/home/FeedTab.tsx @@ -2,8 +2,7 @@ import { ForwardedRef, forwardRef, memo, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { FlatList, RefreshControl } from 'react-native' import Animated from 'react-native-reanimated' -import { useDispatch } from 'react-redux' -import { useAppSelector } from 'src/app/hooks' +import { useAppDispatch, useAppSelector } from 'src/app/hooks' import { useAdaptiveFooter } from 'src/components/home/hooks' import { AnimatedFlatList } from 'src/components/layout/AnimatedFlatList' import { TAB_BAR_HEIGHT, TabProps } from 'src/components/layout/TabHelpers' @@ -41,7 +40,7 @@ export const FeedTab = memo( ref, ) { const { t } = useTranslation() - const dispatch = useDispatch() + const dispatch = useAppDispatch() const colors = useSporeColors() const insets = useDeviceInsets() diff --git a/apps/mobile/src/components/home/NftsTab.tsx b/apps/mobile/src/components/home/NftsTab.tsx index 7659607a2d2..f4a8e185a31 100644 --- a/apps/mobile/src/components/home/NftsTab.tsx +++ b/apps/mobile/src/components/home/NftsTab.tsx @@ -7,7 +7,6 @@ import { useAdaptiveFooter } from 'src/components/home/hooks' import { TAB_BAR_HEIGHT, TabProps } from 'src/components/layout/TabHelpers' import { Flex, useDeviceInsets, useSporeColors } from 'ui/src' import { GQLQueries } from 'uniswap/src/data/graphql/uniswap-data-api/queries' -import { TestID } from 'uniswap/src/test/fixtures/testIDs' import { MobileScreens } from 'uniswap/src/types/screens/mobile' import { isAndroid } from 'utilities/src/platform' import { NftsList } from 'wallet/src/components/nfts/NftsList' @@ -38,7 +37,7 @@ export const NftsTab = memo( ) const renderNFTItem = useCallback( - (item: NFTItem, index: number) => { + (item: NFTItem) => { const onPressNft = (): void => { navigation.navigate(MobileScreens.NFTItem, { owner, @@ -49,7 +48,7 @@ export const NftsTab = memo( }) } - return + return }, [owner, navigation], ) @@ -66,7 +65,7 @@ export const NftsTab = memo( }, [refreshing, headerHeight, onRefresh, colors.neutral3, insets.top]) return ( - + { - dispatch(openModal({ name: ModalName.FiatOnRampAggregator })) - }, [dispatch]) + dispatch( + openModal({ + name: forAggregatorEnabled ? ModalName.FiatOnRampAggregator : ModalName.FiatOnRamp, + }), + ) + }, [dispatch, forAggregatorEnabled]) const onPressReceive = useCallback(() => { dispatch( @@ -86,26 +90,16 @@ export const TokensTab = memo( const renderEmpty = useMemo((): JSX.Element => { // Show different empty state on external profile pages return isExternalProfile ? ( - - } - title={t('home.tokens.empty.title')} - onPress={onPressAction} - /> - + } + title={t('home.tokens.empty.title')} + onPress={onPressAction} + /> ) : ( ) - }, [ - isExternalProfile, - onPressAction, - onPressBuy, - onPressImport, - onPressReceive, - containerProps?.emptyComponentStyle, - t, - ]) + }, [isExternalProfile, onPressAction, onPressBuy, onPressImport, onPressReceive, t]) return ( @@ -119,7 +113,6 @@ export const TokensTab = memo( refreshing={refreshing} renderedInModal={renderedInModal} scrollHandler={scrollHandler} - testID={testID} onPressToken={onPressToken} onRefresh={onRefresh} /> diff --git a/apps/mobile/src/components/layout/SafeKeyboardScreen.tsx b/apps/mobile/src/components/layout/SafeKeyboardScreen.tsx deleted file mode 100644 index ba56667f491..00000000000 --- a/apps/mobile/src/components/layout/SafeKeyboardScreen.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import React, { PropsWithChildren, useState } from 'react' -import { KeyboardAvoidingView, ScrollView, StyleSheet } from 'react-native' -import { Screen, ScreenProps } from 'src/components/layout/Screen' -import { Flex, flexStyles } from 'ui/src' -import { spacing } from 'ui/src/theme' -import { useKeyboardLayout } from 'uniswap/src/utils/useKeyboardLayout' -import { isIOS } from 'utilities/src/platform' - -type OnboardingScreenProps = ScreenProps & { - header?: JSX.Element - footer?: JSX.Element - minHeightWhenKeyboardExpanded?: boolean -} - -export function SafeKeyboardScreen({ - children, - header, - footer, - minHeightWhenKeyboardExpanded = false, - ...screenProps -}: PropsWithChildren): JSX.Element { - const [footerHeight, setFooterHeight] = useState(0) - const keyboard = useKeyboardLayout() - - const compact = keyboard.isVisible && keyboard.containerHeight !== 0 - const containerStyle = compact ? styles.compact : styles.expand - - // This makes sure this component behaves just like `behavior="padding"` when - // there's enough space on the screen to show all components. - const minHeight = minHeightWhenKeyboardExpanded && compact ? keyboard.containerHeight - footerHeight : 0 - - return ( - - - {header} - - - {children} - - - { - setFooterHeight(height) - }} - > - {footer} - - - - ) -} - -const styles = StyleSheet.create({ - base: { - flex: 1, - justifyContent: 'flex-end', - }, - compact: { - flexGrow: 0, - }, - container: { - paddingBottom: spacing.spacing12, - }, - expand: { - flexGrow: 1, - }, -}) diff --git a/apps/mobile/src/components/layout/Screen.tsx b/apps/mobile/src/components/layout/Screen.tsx index 7dd87c1d712..2e88c90432a 100644 --- a/apps/mobile/src/components/layout/Screen.tsx +++ b/apps/mobile/src/components/layout/Screen.tsx @@ -5,7 +5,7 @@ import { Flex, FlexProps, useDeviceInsets } from 'ui/src' // Used to determine amount of top padding for short screens export const SHORT_SCREEN_HEADER_HEIGHT_RATIO = 0.88 -export type ScreenProps = FlexProps & +type ScreenProps = FlexProps & // The SafeAreaView from react-native-safe-area-context also supports a `mode` prop which // lets you choose if `edges` are added as margin or padding, but we don’t use that so // our Screen component doesn't need to support it diff --git a/apps/mobile/src/components/layout/TabHelpers.tsx b/apps/mobile/src/components/layout/TabHelpers.tsx index e441424ffb4..36a0f603484 100644 --- a/apps/mobile/src/components/layout/TabHelpers.tsx +++ b/apps/mobile/src/components/layout/TabHelpers.tsx @@ -85,7 +85,6 @@ export type TabProps = { refreshing?: boolean onRefresh?: () => void headerHeight?: number - testID?: string } export type TabContentProps = Partial> & { @@ -107,7 +106,7 @@ export const TabLabel = ({ isExternalProfile?: boolean }): JSX.Element => { return ( - + {route.title} diff --git a/apps/mobile/src/components/mnemonic/HiddenMnemonicWordView.tsx b/apps/mobile/src/components/mnemonic/HiddenMnemonicWordView.tsx index 142df3b4087..2f25f72e983 100644 --- a/apps/mobile/src/components/mnemonic/HiddenMnemonicWordView.tsx +++ b/apps/mobile/src/components/mnemonic/HiddenMnemonicWordView.tsx @@ -11,22 +11,27 @@ export function HiddenMnemonicWordView(): JSX.Element { backgroundColor="$surface2" borderRadius="$rounded20" gap="$spacing36" + height="40%" mt="$spacing16" px="$spacing32" py="$spacing24" > - - + + + + + + ) } function HiddenWordViewColumn(): JSX.Element { return ( - + <> {new Array(ROW_COUNT).fill(0).map((_, idx) => ( ))} - + ) } diff --git a/apps/mobile/src/components/mnemonic/SeedPhraseDisplay.tsx b/apps/mobile/src/components/mnemonic/SeedPhraseDisplay.tsx index 1b30e231b53..5e5c238a400 100644 --- a/apps/mobile/src/components/mnemonic/SeedPhraseDisplay.tsx +++ b/apps/mobile/src/components/mnemonic/SeedPhraseDisplay.tsx @@ -65,9 +65,7 @@ export function SeedPhraseDisplay({ mnemonicId, onDismiss, walletNeedsRestore }: ) : ( - - - + )} diff --git a/apps/mobile/src/components/text/LongMarkdownText.tsx b/apps/mobile/src/components/text/LongMarkdownText.tsx index 7905472efd0..e33d6a80755 100644 --- a/apps/mobile/src/components/text/LongMarkdownText.tsx +++ b/apps/mobile/src/components/text/LongMarkdownText.tsx @@ -4,7 +4,7 @@ import { LayoutChangeEvent } from 'react-native' import Markdown, { MarkdownProps } from 'react-native-markdown-display' import { Flex, SpaceTokens, Text, useSporeColors } from 'ui/src' import { fonts } from 'ui/src/theme' -import { openUri } from 'uniswap/src/utils/linking' +import { openUri } from 'wallet/src/utils/linking' type LongMarkdownTextProps = { initialDisplayedLines?: number diff --git a/apps/mobile/src/components/unitags/ChangeUnitagModal.tsx b/apps/mobile/src/components/unitags/ChangeUnitagModal.tsx index bacaa3e5156..d58b4b39381 100644 --- a/apps/mobile/src/components/unitags/ChangeUnitagModal.tsx +++ b/apps/mobile/src/components/unitags/ChangeUnitagModal.tsx @@ -3,7 +3,6 @@ import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { ActivityIndicator, EmitterSubscription, Keyboard } from 'react-native' import { getUniqueId } from 'react-native-device-info' -import { useDispatch } from 'react-redux' import { Button, Flex, Text, useSporeColors } from 'ui/src' import { AlertTriangle } from 'ui/src/components/icons' import { fonts, spacing } from 'ui/src/theme' @@ -25,6 +24,7 @@ import { useCanAddressClaimUnitag, useCanClaimUnitagName } from 'wallet/src/feat import { parseUnitagErrorCode } from 'wallet/src/features/unitags/utils' import { useWalletSigners } from 'wallet/src/features/wallet/context' import { useAccount } from 'wallet/src/features/wallet/hooks' +import { useAppDispatch } from 'wallet/src/state' export function ChangeUnitagModal({ unitag, @@ -38,7 +38,7 @@ export function ChangeUnitagModal({ const { t } = useTranslation() const colors = useSporeColors() const navigation = useNavigation() - const dispatch = useDispatch() + const dispatch = useAppDispatch() const { data: deviceId } = useAsyncData(getUniqueId) const account = useAccount(address) const signerManager = useWalletSigners() diff --git a/apps/mobile/src/components/unitags/DeleteUnitagModal.tsx b/apps/mobile/src/components/unitags/DeleteUnitagModal.tsx index a9782c14530..e36ee7f4070 100644 --- a/apps/mobile/src/components/unitags/DeleteUnitagModal.tsx +++ b/apps/mobile/src/components/unitags/DeleteUnitagModal.tsx @@ -2,7 +2,6 @@ import { useNavigation } from '@react-navigation/native' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { ActivityIndicator } from 'react-native' -import { useDispatch } from 'react-redux' import { Button, Flex, Text, useSporeColors } from 'ui/src' import { AlertTriangle } from 'ui/src/components/icons' import { fonts } from 'ui/src/theme' @@ -17,6 +16,7 @@ import { AppNotificationType } from 'wallet/src/features/notifications/types' import { deleteUnitag } from 'wallet/src/features/unitags/api' import { useWalletSigners } from 'wallet/src/features/wallet/context' import { useAccount } from 'wallet/src/features/wallet/hooks' +import { useAppDispatch } from 'wallet/src/state' export function DeleteUnitagModal({ unitag, @@ -30,7 +30,7 @@ export function DeleteUnitagModal({ const { t } = useTranslation() const colors = useSporeColors() const navigation = useNavigation() - const dispatch = useDispatch() + const dispatch = useAppDispatch() const { triggerRefetchUnitags } = useUnitagUpdater() const account = useAccount(address) const signerManager = useWalletSigners() diff --git a/apps/mobile/src/components/unitags/UnitagBanner.tsx b/apps/mobile/src/components/unitags/UnitagBanner.tsx index cc5efe4e0bb..8e10a952282 100644 --- a/apps/mobile/src/components/unitags/UnitagBanner.tsx +++ b/apps/mobile/src/components/unitags/UnitagBanner.tsx @@ -1,7 +1,7 @@ import React from 'react' import { Trans, useTranslation } from 'react-i18next' import { Keyboard } from 'react-native' -import { useDispatch } from 'react-redux' +import { useAppDispatch } from 'src/app/hooks' import { navigate } from 'src/app/navigation/rootNavigation' import { openModal } from 'src/features/modals/modalSlice' import { Flex, Image, Text, TouchableArea, TouchableAreaProps, useIsDarkMode, useIsShortMobileDevice } from 'ui/src' @@ -30,7 +30,7 @@ export function UnitagBanner({ compact?: boolean entryPoint: MobileScreens.Home | MobileScreens.Settings }): JSX.Element { - const dispatch = useDispatch() + const dispatch = useAppDispatch() const { t } = useTranslation() const { fullWidth } = useDeviceDimensions() const isDarkMode = useIsDarkMode() diff --git a/apps/mobile/src/components/unitags/UnitagsIntroModal.tsx b/apps/mobile/src/components/unitags/UnitagsIntroModal.tsx index c3e2def592b..2b2472893a3 100644 --- a/apps/mobile/src/components/unitags/UnitagsIntroModal.tsx +++ b/apps/mobile/src/components/unitags/UnitagsIntroModal.tsx @@ -2,8 +2,7 @@ import { SharedEventName } from '@uniswap/analytics-events' import React from 'react' import { useTranslation } from 'react-i18next' import 'react-native-reanimated' -import { useDispatch } from 'react-redux' -import { useAppSelector } from 'src/app/hooks' +import { useAppDispatch, useAppSelector } from 'src/app/hooks' import { navigate } from 'src/app/navigation/rootNavigation' import { closeModal } from 'src/features/modals/modalSlice' import { selectModalState } from 'src/features/modals/selectModalState' @@ -21,7 +20,7 @@ import { setHasCompletedUnitagsIntroModal } from 'wallet/src/features/behaviorHi export function UnitagsIntroModal(): JSX.Element { const { t } = useTranslation() const isDarkMode = useIsDarkMode() - const appDispatch = useDispatch() + const appDispatch = useAppDispatch() const modalState = useAppSelector(selectModalState(ModalName.UnitagsIntro)).initialState const address = modalState?.address const entryPoint = modalState?.entryPoint diff --git a/apps/mobile/src/features/CloudBackup/CloudBackupForm/CloudBackupPasswordFormContext.tsx b/apps/mobile/src/features/CloudBackup/CloudBackupForm/CloudBackupPasswordFormContext.tsx deleted file mode 100644 index 6b43b191058..00000000000 --- a/apps/mobile/src/features/CloudBackup/CloudBackupForm/CloudBackupPasswordFormContext.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { PropsWithChildren, createContext, useCallback, useContext, useMemo, useState } from 'react' -import { Keyboard } from 'react-native' -import { - PasswordErrors, - PasswordStrength, - getPasswordStrength, - isPasswordStrongEnough, -} from 'wallet/src/utils/password' - -export type CloudBackupPasswordFormContextType = { - password: string - passwordStrength: PasswordStrength - error: PasswordErrors | undefined - isConfirmation: boolean - isInputValid: boolean - onPressNext: () => void - onPasswordSubmitEditing: () => void - onPasswordChangeText: (newPassword: string) => void -} - -export const CloudBackupPasswordFormContext = createContext(null) - -export function useCloudBackupPasswordFormContext(): CloudBackupPasswordFormContextType { - const context = useContext(CloudBackupPasswordFormContext) - - if (!context) { - throw new Error('useCloudBackupPasswordFormContext must be used within a CloudBackupPasswordFormContextProvider') - } - - return context -} - -type CloudBackupPasswordFormContextProviderProps = PropsWithChildren<{ - isConfirmation?: boolean - passwordToConfirm?: string - navigateToNextScreen: ({ password }: { password: string }) => void -}> - -export function CloudBackupPasswordFormContextProvider({ - children, - isConfirmation = false, - passwordToConfirm, - navigateToNextScreen, -}: CloudBackupPasswordFormContextProviderProps): JSX.Element { - const [password, setPassword] = useState('') - const [error, setError] = useState(undefined) - const [passwordStrength, setPasswordStrength] = useState(PasswordStrength.NONE) - - const isStrongPassword = isPasswordStrongEnough({ - minStrength: PasswordStrength.MEDIUM, - currentStrength: passwordStrength, - }) - - const isInputValid = !error && password.length > 0 && (isConfirmation || isStrongPassword) - - const onPasswordChangeText = useCallback( - (newPassword: string): void => { - if (isConfirmation && newPassword === password) { - setError(undefined) - } - // always reset error if not confirmation - if (!isConfirmation) { - setPasswordStrength(getPasswordStrength(newPassword)) - setError(undefined) - } - setPassword(newPassword) - }, - [isConfirmation, password], - ) - - const onPasswordSubmitEditing = useCallback((): void => { - if (!isConfirmation && !isStrongPassword) { - return - } - if (isConfirmation && passwordToConfirm !== password) { - setError(PasswordErrors.PasswordsDoNotMatch) - return - } - setError(undefined) - Keyboard.dismiss() - }, [isConfirmation, isStrongPassword, password, passwordToConfirm]) - - const onPressNext = useCallback((): void => { - if (isConfirmation && passwordToConfirm !== password) { - setError(PasswordErrors.PasswordsDoNotMatch) - return - } - - if (!error) { - navigateToNextScreen({ password }) - } - }, [error, isConfirmation, navigateToNextScreen, password, passwordToConfirm]) - - const contextValue = useMemo( - () => ({ - password, - passwordStrength, - error, - isConfirmation, - isInputValid, - onPressNext, - onPasswordChangeText, - onPasswordSubmitEditing, - }), - [ - error, - passwordStrength, - isConfirmation, - isInputValid, - onPressNext, - onPasswordChangeText, - onPasswordSubmitEditing, - password, - ], - ) - - return ( - {children} - ) -} diff --git a/apps/mobile/src/features/CloudBackup/CloudBackupForm/ContinueButton.tsx b/apps/mobile/src/features/CloudBackup/CloudBackupForm/ContinueButton.tsx deleted file mode 100644 index 19776aae667..00000000000 --- a/apps/mobile/src/features/CloudBackup/CloudBackupForm/ContinueButton.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { useTranslation } from 'react-i18next' -import { useCloudBackupPasswordFormContext } from 'src/features/CloudBackup/CloudBackupForm/CloudBackupPasswordFormContext' -import { Button } from 'ui/src' -import { TestID } from 'uniswap/src/test/fixtures/testIDs' - -export function ContinueButton(): JSX.Element { - const { isInputValid, onPressNext } = useCloudBackupPasswordFormContext() - - const { t } = useTranslation() - - return ( - - ) -} diff --git a/apps/mobile/src/features/CloudBackup/CloudBackupForm/PasswordInput.tsx b/apps/mobile/src/features/CloudBackup/CloudBackupForm/PasswordInput.tsx deleted file mode 100644 index 1726249dea1..00000000000 --- a/apps/mobile/src/features/CloudBackup/CloudBackupForm/PasswordInput.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { useRef } from 'react' -import { useTranslation } from 'react-i18next' -import { TextInput } from 'react-native' -import { PasswordInput as Input } from 'src/components/input/PasswordInput' -import { useCloudBackupPasswordFormContext } from 'src/features/CloudBackup/CloudBackupForm/CloudBackupPasswordFormContext' -import { PasswordError } from 'src/features/onboarding/PasswordError' -import { Flex, Text } from 'ui/src' -import { DiamondExclamation } from 'ui/src/components/icons' -import { iconSizes } from 'ui/src/theme' -import { useDebounce } from 'utilities/src/time/timing' -import { - PASSWORD_VALIDATION_DEBOUNCE_MS, - PasswordErrors, - PasswordStrength, - getPasswordStrengthTextAndColor, -} from 'wallet/src/utils/password' - -export function PasswordInput(): JSX.Element { - const { password, error, passwordStrength, isConfirmation, onPasswordChangeText, onPasswordSubmitEditing } = - useCloudBackupPasswordFormContext() - const debouncedPasswordStrength = useDebounce(passwordStrength, PASSWORD_VALIDATION_DEBOUNCE_MS) - - const { t } = useTranslation() - const passwordInputRef = useRef(null) - - let errorText = '' - if (error === PasswordErrors.PasswordsDoNotMatch) { - errorText = t('settings.setting.backup.password.error.mismatch') - } else if (error) { - // use the upstream zxcvbn error message - errorText = error - } - - return ( - - - { - onPasswordChangeText(newText) - }} - onSubmitEditing={onPasswordSubmitEditing} - /> - {!isConfirmation && } - {error ? : null} - - {!isConfirmation && ( - - - - {t('settings.setting.backup.password.disclaimer')} - - - )} - - ) -} - -function PasswordStrengthText({ strength }: { strength: PasswordStrength }): JSX.Element { - const { t } = useTranslation() - const { color } = getPasswordStrengthTextAndColor(strength) - - const hasPassword = strength !== PasswordStrength.NONE - let strengthText: string = '' - switch (strength) { - case PasswordStrength.STRONG: - strengthText = t('settings.setting.backup.password.strong') - break - case PasswordStrength.MEDIUM: - strengthText = t('settings.setting.backup.password.medium') - break - case PasswordStrength.WEAK: - strengthText = t('settings.setting.backup.password.weak') - break - default: - break - } - - return ( - - - {strengthText} - - - ) -} diff --git a/apps/mobile/src/features/CloudBackup/CloudBackupForm/index.ts b/apps/mobile/src/features/CloudBackup/CloudBackupForm/index.ts deleted file mode 100644 index fa72f4b3769..00000000000 --- a/apps/mobile/src/features/CloudBackup/CloudBackupForm/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { CloudBackupPasswordFormContextProvider } from 'src/features/CloudBackup/CloudBackupForm/CloudBackupPasswordFormContext' -import { ContinueButton } from 'src/features/CloudBackup/CloudBackupForm/ContinueButton' -import { PasswordInput } from 'src/features/CloudBackup/CloudBackupForm/PasswordInput' - -export const CloudBackupPassword = { - PasswordInput, - ContinueButton, - FormProvider: CloudBackupPasswordFormContextProvider, -} diff --git a/apps/mobile/src/features/CloudBackup/CloudBackupPasswordForm.tsx b/apps/mobile/src/features/CloudBackup/CloudBackupPasswordForm.tsx new file mode 100644 index 00000000000..6effc6f4d33 --- /dev/null +++ b/apps/mobile/src/features/CloudBackup/CloudBackupPasswordForm.tsx @@ -0,0 +1,155 @@ +import React, { useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Keyboard, TextInput } from 'react-native' +import { PasswordInput } from 'src/components/input/PasswordInput' +import { PasswordError } from 'src/features/onboarding/PasswordError' +import { Button, Flex, Text } from 'ui/src' +import { DiamondExclamation } from 'ui/src/components/icons' +import { iconSizes } from 'ui/src/theme' +import { TestID } from 'uniswap/src/test/fixtures/testIDs' +import { useDebounce } from 'utilities/src/time/timing' +import { + PASSWORD_VALIDATION_DEBOUNCE_MS, + PasswordErrors, + PasswordStrength, + getPasswordStrength, + getPasswordStrengthTextAndColor, + isPasswordStrongEnough, +} from 'wallet/src/utils/password' + +export type CloudBackupPasswordProps = { + navigateToNextScreen: ({ password }: { password: string }) => void + isConfirmation?: boolean + passwordToConfirm?: string +} + +export function CloudBackupPasswordForm({ + navigateToNextScreen, + isConfirmation, + passwordToConfirm, +}: CloudBackupPasswordProps): JSX.Element { + const { t } = useTranslation() + + const passwordInputRef = useRef(null) + const [password, setPassword] = useState('') + + const [error, setError] = useState(undefined) + + const [passwordStrength, setPasswordStrength] = useState(PasswordStrength.NONE) + const debouncedPasswordStrength = useDebounce(passwordStrength, PASSWORD_VALIDATION_DEBOUNCE_MS) + const isStrongPassword = isPasswordStrongEnough({ + minStrength: PasswordStrength.MEDIUM, + currentStrength: passwordStrength, + }) + + const isButtonDisabled = !!error || password.length === 0 || (!isConfirmation && !isStrongPassword) + + const onPasswordChangeText = (newPassword: string): void => { + if (isConfirmation && newPassword === password) { + setError(undefined) + } + // always reset error if not confirmation + if (!isConfirmation) { + setPasswordStrength(getPasswordStrength(newPassword)) + setError(undefined) + } + setPassword(newPassword) + } + + const onPasswordSubmitEditing = (): void => { + if (!isConfirmation && !isStrongPassword) { + return + } + if (isConfirmation && passwordToConfirm !== password) { + setError(PasswordErrors.PasswordsDoNotMatch) + return + } + setError(undefined) + Keyboard.dismiss() + } + + const onPressNext = (): void => { + if (isConfirmation && passwordToConfirm !== password) { + setError(PasswordErrors.PasswordsDoNotMatch) + return + } + + if (!error) { + navigateToNextScreen({ password }) + } + } + + let errorText = '' + if (error === PasswordErrors.PasswordsDoNotMatch) { + errorText = t('settings.setting.backup.password.error.mismatch') + } else if (error) { + // use the upstream zxcvbn error message + errorText = error + } + + return ( + <> + + + { + setError(undefined) + onPasswordChangeText(newText) + }} + onSubmitEditing={onPasswordSubmitEditing} + /> + {!isConfirmation && } + {error ? : null} + + {!isConfirmation && ( + + + + {t('settings.setting.backup.password.disclaimer')} + + + )} + + + + ) +} + +function PasswordStrengthText({ strength }: { strength: PasswordStrength }): JSX.Element { + const { t } = useTranslation() + const { color } = getPasswordStrengthTextAndColor(strength) + + const hasPassword = strength !== PasswordStrength.NONE + let strengthText: string = '' + switch (strength) { + case PasswordStrength.STRONG: + strengthText = t('settings.setting.backup.password.strong') + break + case PasswordStrength.MEDIUM: + strengthText = t('settings.setting.backup.password.medium') + break + case PasswordStrength.WEAK: + strengthText = t('settings.setting.backup.password.weak') + break + default: + break + } + + return ( + + + {strengthText} + + + ) +} diff --git a/apps/mobile/src/features/CloudBackup/CloudBackupProcessingAnimation.tsx b/apps/mobile/src/features/CloudBackup/CloudBackupProcessingAnimation.tsx index efab83194da..3396fbaa1f8 100644 --- a/apps/mobile/src/features/CloudBackup/CloudBackupProcessingAnimation.tsx +++ b/apps/mobile/src/features/CloudBackup/CloudBackupProcessingAnimation.tsx @@ -3,7 +3,6 @@ import { NativeStackNavigationProp } from '@react-navigation/native-stack' import React, { useCallback, useEffect, useReducer } from 'react' import { useTranslation } from 'react-i18next' import { ActivityIndicator, Alert } from 'react-native' -import { useDispatch } from 'react-redux' import { OnboardingStackParamList, SettingsStackParamList } from 'src/app/navigation/types' import { backupMnemonicToCloudStorage } from 'src/features/CloudBackup/RNCloudStorageBackupsManager' import { Flex, Text } from 'ui/src' @@ -18,6 +17,7 @@ import { useOnboardingContext } from 'wallet/src/features/onboarding/OnboardingC import { EditAccountAction, editAccountActions } from 'wallet/src/features/wallet/accounts/editAccountSaga' import { AccountType, BackupType } from 'wallet/src/features/wallet/accounts/types' import { useSignerAccountIfExists } from 'wallet/src/features/wallet/hooks' +import { useAppDispatch } from 'wallet/src/state' type Props = { accountAddress: Address @@ -38,7 +38,7 @@ export function CloudBackupProcessingAnimation({ navigation, }: Props): JSX.Element { const { t } = useTranslation() - const dispatch = useDispatch() + const dispatch = useAppDispatch() const { addBackupMethod, getImportedAccounts, getOnboardingAccount } = useOnboardingContext() const onboardingAccount = getOnboardingAccount() const importedAccounts = getImportedAccounts() diff --git a/apps/mobile/src/features/appRating/saga.ts b/apps/mobile/src/features/appRating/saga.ts index 3bd4a1e8de0..55a83635310 100644 --- a/apps/mobile/src/features/appRating/saga.ts +++ b/apps/mobile/src/features/appRating/saga.ts @@ -8,7 +8,6 @@ import { Statsig } from 'uniswap/src/features/gating/sdk/statsig' import { MobileEventName } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import i18n from 'uniswap/src/i18n/i18n' -import { openUri } from 'uniswap/src/utils/linking' import { logger } from 'utilities/src/logger/logger' import { isAndroid } from 'utilities/src/platform' import { ONE_DAY_MS, ONE_SECOND_MS } from 'utilities/src/time/time' @@ -17,6 +16,7 @@ import { TransactionStatus, TransactionType } from 'wallet/src/features/transact import { selectActiveAccountAddress } from 'wallet/src/features/wallet/selectors' import { setAppRating } from 'wallet/src/features/wallet/slice' import { appSelect } from 'wallet/src/state' +import { openUri } from 'wallet/src/utils/linking' // at most once per reminder period (120 days) const MIN_PROMPT_REMINDER_MS = 120 * ONE_DAY_MS diff --git a/apps/mobile/src/features/appRating/selectors.test.ts b/apps/mobile/src/features/appRating/selectors.test.ts index f99afcd35c7..becc2b51cbe 100644 --- a/apps/mobile/src/features/appRating/selectors.test.ts +++ b/apps/mobile/src/features/appRating/selectors.test.ts @@ -1,7 +1,11 @@ import { hasConsecutiveRecentSwapsSelector } from 'src/features/appRating/selectors' import { UniverseChainId } from 'uniswap/src/types/chains' import { ONE_HOUR_MS, ONE_MINUTE_MS } from 'utilities/src/time/time' -import { TransactionDetails, TransactionStatus, TransactionType } from 'wallet/src/features/transactions/types' +import { + TransactionDetails, + TransactionStatus, + TransactionType, +} from 'wallet/src/features/transactions/types' import { RootState } from 'wallet/src/state' import { signerMnemonicAccount } from 'wallet/src/test/fixtures' import { preloadedWalletState } from 'wallet/src/test/fixtures/wallet/redux' diff --git a/apps/mobile/src/features/dataApi/balances.test.ts b/apps/mobile/src/features/dataApi/balances.test.ts index 0bb18abd31b..1e9d2ca605c 100644 --- a/apps/mobile/src/features/dataApi/balances.test.ts +++ b/apps/mobile/src/features/dataApi/balances.test.ts @@ -35,10 +35,10 @@ describe(useBalances, () => { const { resolvers } = queryResolvers({ portfolios: () => [Portfolio], }) - const { result } = renderHook(() => useBalances(balances.map(({ currencyInfo: { currencyId } }) => currencyId)), { - preloadedState, - resolvers, - }) + const { result } = renderHook( + () => useBalances(balances.map(({ currencyInfo: { currencyId } }) => currencyId)), + { preloadedState, resolvers } + ) await waitFor(() => { // The response contains only the first currency as the second one is not in the portfolio diff --git a/apps/mobile/src/features/deepLinking/handleDeepLinkSaga.ts b/apps/mobile/src/features/deepLinking/handleDeepLinkSaga.ts index bd95777755b..130001f2993 100644 --- a/apps/mobile/src/features/deepLinking/handleDeepLinkSaga.ts +++ b/apps/mobile/src/features/deepLinking/handleDeepLinkSaga.ts @@ -4,13 +4,13 @@ import { Alert } from 'react-native' import { URL } from 'react-native-url-polyfill' import { appSelect } from 'src/app/hooks' import { navigate } from 'src/app/navigation/rootNavigation' -import { getScantasticQueryParams, parseScantasticParams } from 'src/components/Requests/ScanSheet/util' +import { getScantasticQueryParams, parseScantasticParams } from 'src/components/WalletConnect/ScanSheet/util' import { UNISWAP_URL_SCHEME, UNISWAP_URL_SCHEME_WALLETCONNECT_AS_PARAM, UNISWAP_WALLETCONNECT_URL, } from 'src/features/deepLinking/constants' -import { handleOnRampReturnLink } from 'src/features/deepLinking/handleOnRampReturnLinkSaga' +import { handleMoonpayReturnLink } from 'src/features/deepLinking/handleMoonpayReturnLinkSaga' import { handleSwapLink } from 'src/features/deepLinking/handleSwapLinkSaga' import { handleTransactionLink } from 'src/features/deepLinking/handleTransactionLinkSaga' import { closeAllModals, openModal } from 'src/features/modals/modalSlice' @@ -29,12 +29,11 @@ import { MobileScreens } from 'uniswap/src/types/screens/mobile' import { ShareableEntity } from 'uniswap/src/types/sharing' import { WidgetType } from 'uniswap/src/types/widgets' import { buildCurrencyId, buildNativeCurrencyId } from 'uniswap/src/utils/currencyId' -import { openUri } from 'uniswap/src/utils/linking' import { logger } from 'utilities/src/logger/logger' import { ScantasticParams } from 'wallet/src/features/scantastic/types' import { selectAccounts, selectActiveAccount, selectActiveAccountAddress } from 'wallet/src/features/wallet/selectors' import { setAccountAsActive } from 'wallet/src/features/wallet/slice' -import { UNISWAP_APP_NATIVE_TOKEN } from 'wallet/src/utils/linking' +import { UNISWAP_APP_NATIVE_TOKEN, openUri } from 'wallet/src/utils/linking' export interface DeepLink { url: string @@ -288,7 +287,7 @@ export function* handleDeepLink(action: ReturnType) { switch (screen) { case 'transaction': if (fiatOnRamp) { - yield* call(handleOnRampReturnLink) + yield* call(handleMoonpayReturnLink) } else { yield* call(handleTransactionLink) } diff --git a/apps/mobile/src/features/deepLinking/handleOnRampReturnLinkSaga.test.ts b/apps/mobile/src/features/deepLinking/handleMoonpayReturnLinkSaga.test.ts similarity index 78% rename from apps/mobile/src/features/deepLinking/handleOnRampReturnLinkSaga.test.ts rename to apps/mobile/src/features/deepLinking/handleMoonpayReturnLinkSaga.test.ts index 59d4de9cf4e..0ba9a9bdae3 100644 --- a/apps/mobile/src/features/deepLinking/handleOnRampReturnLinkSaga.test.ts +++ b/apps/mobile/src/features/deepLinking/handleMoonpayReturnLinkSaga.test.ts @@ -1,15 +1,15 @@ import { call, put } from '@redux-saga/core/effects' import { expectSaga } from 'redux-saga-test-plan' import { navigate } from 'src/app/navigation/rootNavigation' -import { handleOnRampReturnLink } from 'src/features/deepLinking/handleOnRampReturnLinkSaga' +import { handleMoonpayReturnLink } from 'src/features/deepLinking/handleMoonpayReturnLinkSaga' import { HomeScreenTabIndex } from 'src/screens/HomeScreenTabIndex' import { MobileScreens } from 'uniswap/src/types/screens/mobile' import { forceFetchFiatOnRampTransactions } from 'wallet/src/features/transactions/slice' import { dismissInAppBrowser } from 'wallet/src/utils/linking' -describe(handleOnRampReturnLink, () => { - it('Navigates to the home screen activity tab when coming back from on-ramp widget', () => { - return expectSaga(handleOnRampReturnLink) +describe(handleMoonpayReturnLink, () => { + it('Navigates to the home screen activity tab when coming back from moonpay', () => { + return expectSaga(handleMoonpayReturnLink) .provide([ [put(forceFetchFiatOnRampTransactions), undefined], [call(navigate, MobileScreens.Home, { tab: HomeScreenTabIndex.Activity }), undefined], diff --git a/apps/mobile/src/features/deepLinking/handleOnRampReturnLinkSaga.ts b/apps/mobile/src/features/deepLinking/handleMoonpayReturnLinkSaga.ts similarity index 92% rename from apps/mobile/src/features/deepLinking/handleOnRampReturnLinkSaga.ts rename to apps/mobile/src/features/deepLinking/handleMoonpayReturnLinkSaga.ts index 19c0a67eeb1..adbc437ebc9 100644 --- a/apps/mobile/src/features/deepLinking/handleOnRampReturnLinkSaga.ts +++ b/apps/mobile/src/features/deepLinking/handleMoonpayReturnLinkSaga.ts @@ -5,7 +5,7 @@ import { MobileScreens } from 'uniswap/src/types/screens/mobile' import { forceFetchFiatOnRampTransactions } from 'wallet/src/features/transactions/slice' import { dismissInAppBrowser } from 'wallet/src/utils/linking' -export function* handleOnRampReturnLink() { +export function* handleMoonpayReturnLink() { yield* put(forceFetchFiatOnRampTransactions()) yield* call(navigate, MobileScreens.Home, { tab: HomeScreenTabIndex.Activity }) yield* call(dismissInAppBrowser) diff --git a/apps/mobile/src/features/deepLinking/handleSwapLinkSaga.test.ts b/apps/mobile/src/features/deepLinking/handleSwapLinkSaga.test.ts index 47719d2e6e1..7a36fa7cbee 100644 --- a/apps/mobile/src/features/deepLinking/handleSwapLinkSaga.test.ts +++ b/apps/mobile/src/features/deepLinking/handleSwapLinkSaga.test.ts @@ -2,11 +2,14 @@ import { URL } from 'react-native-url-polyfill' import { expectSaga } from 'redux-saga-test-plan' import { handleSwapLink } from 'src/features/deepLinking/handleSwapLinkSaga' import { openModal } from 'src/features/modals/modalSlice' -import { DAI, UNI } from 'uniswap/src/constants/tokens' -import { AssetType } from 'uniswap/src/entities/assets' import { ModalName } from 'uniswap/src/features/telemetry/constants' -import { CurrencyField, TransactionState } from 'uniswap/src/features/transactions/transactionState/types' import { UniverseChainId, WalletChainId } from 'uniswap/src/types/chains' +import { DAI, UNI } from 'wallet/src/constants/tokens' +import { AssetType } from 'wallet/src/entities/assets' +import { + CurrencyField, + TransactionState, +} from 'wallet/src/features/transactions/transactionState/types' import { signerMnemonicAccount } from 'wallet/src/test/fixtures' const account = signerMnemonicAccount() @@ -17,7 +20,7 @@ const formSwapUrl = ( inputAddress?: string, outputAddress?: string, currencyField?: string, - amount?: string, + amount?: string ): URL => new URL( `https://uniswap.org/app?screen=swap @@ -25,7 +28,7 @@ const formSwapUrl = ( &inputCurrencyId=${chain}-${inputAddress} &outputCurrencyId=${chain}-${outputAddress} ¤cyField=${currencyField} -&amount=${amount}`.trim(), +&amount=${amount}`.trim() ) const formTransactionState = ( @@ -33,7 +36,7 @@ const formTransactionState = ( inputAddress?: string, outputAddress?: string, currencyField?: string, - amount?: string, + amount?: string ): { input: { address: string | undefined @@ -61,8 +64,8 @@ const formTransactionState = ( exactCurrencyField: !currencyField ? currencyField : currencyField.toLowerCase() === 'output' - ? CurrencyField.OUTPUT - : CurrencyField.INPUT, + ? CurrencyField.OUTPUT + : CurrencyField.INPUT, exactAmountToken: amount, }) @@ -72,7 +75,7 @@ const swapUrl = formSwapUrl( DAI.address, UNI[UniverseChainId.Mainnet].address, 'input', - '100', + '100' ) const invalidOutputCurrencySwapUrl = formSwapUrl( @@ -81,7 +84,7 @@ const invalidOutputCurrencySwapUrl = formSwapUrl( DAI.address, undefined, 'input', - '100', + '100' ) const invalidInputTokenSwapURl = formSwapUrl( @@ -90,7 +93,7 @@ const invalidInputTokenSwapURl = formSwapUrl( '0x00', UNI[UniverseChainId.Mainnet].address, 'input', - '100', + '100' ) const invalidChainSwapUrl = formSwapUrl( @@ -99,7 +102,7 @@ const invalidChainSwapUrl = formSwapUrl( DAI.address, UNI[UniverseChainId.Mainnet].address, 'input', - '100', + '100' ) const invalidAmountSwapUrl = formSwapUrl( @@ -108,7 +111,7 @@ const invalidAmountSwapUrl = formSwapUrl( DAI.address, UNI[UniverseChainId.Mainnet].address, 'input', - 'not a number', + 'not a number' ) const invalidCurrencyFieldSwapUrl = formSwapUrl( @@ -117,7 +120,7 @@ const invalidCurrencyFieldSwapUrl = formSwapUrl( DAI.address, UNI[UniverseChainId.Mainnet].address, 'token1', - '100', + '100' ) const swapFormState = formTransactionState( @@ -125,7 +128,7 @@ const swapFormState = formTransactionState( DAI.address, UNI[UniverseChainId.Mainnet].address, 'input', - '100', + '100' ) as TransactionState describe(handleSwapLink, () => { diff --git a/apps/mobile/src/features/deepLinking/handleSwapLinkSaga.ts b/apps/mobile/src/features/deepLinking/handleSwapLinkSaga.ts index 9a0eae1b405..263e70b6285 100644 --- a/apps/mobile/src/features/deepLinking/handleSwapLinkSaga.ts +++ b/apps/mobile/src/features/deepLinking/handleSwapLinkSaga.ts @@ -1,13 +1,13 @@ import { BigNumber } from 'ethers' import { openModal } from 'src/features/modals/modalSlice' import { put } from 'typed-redux-saga' -import { AssetType, CurrencyAsset } from 'uniswap/src/entities/assets' import { ModalName } from 'uniswap/src/features/telemetry/constants' -import { CurrencyField, TransactionState } from 'uniswap/src/features/transactions/transactionState/types' import { WALLET_SUPPORTED_CHAIN_IDS } from 'uniswap/src/types/chains' import { getValidAddress } from 'uniswap/src/utils/addresses' import { currencyIdToAddress, currencyIdToChain } from 'uniswap/src/utils/currencyId' import { logger } from 'utilities/src/logger/logger' +import { AssetType, CurrencyAsset } from 'wallet/src/entities/assets' +import { CurrencyField, TransactionState } from 'wallet/src/features/transactions/transactionState/types' export function* handleSwapLink(url: URL) { try { diff --git a/apps/mobile/src/features/externalProfile/ProfileContextMenu.tsx b/apps/mobile/src/features/externalProfile/ProfileContextMenu.tsx index 890af5e21bb..454b1fc4e03 100644 --- a/apps/mobile/src/features/externalProfile/ProfileContextMenu.tsx +++ b/apps/mobile/src/features/externalProfile/ProfileContextMenu.tsx @@ -3,7 +3,7 @@ import React, { useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { NativeSyntheticEvent, Share } from 'react-native' import ContextMenu, { ContextMenuOnPressNativeEvent } from 'react-native-context-menu-view' -import { useDispatch } from 'react-redux' +import { useAppDispatch } from 'src/app/hooks' import { TripleDot } from 'src/components/icons/TripleDot' import { disableOnPress } from 'src/utils/disableOnPress' import { Flex, HapticFeedback, TouchableArea } from 'ui/src' @@ -16,12 +16,11 @@ import { useUnitagByAddress } from 'uniswap/src/features/unitags/hooks' import { UniverseChainId } from 'uniswap/src/types/chains' import { MobileScreens } from 'uniswap/src/types/screens/mobile' import { ShareableEntity } from 'uniswap/src/types/sharing' -import { setClipboard } from 'uniswap/src/utils/clipboard' -import { openUri } from 'uniswap/src/utils/linking' import { logger } from 'utilities/src/logger/logger' import { pushNotification } from 'wallet/src/features/notifications/slice' import { AppNotificationType, CopyNotificationType } from 'wallet/src/features/notifications/types' -import { ExplorerDataType, getExplorerLink, getProfileUrl } from 'wallet/src/utils/linking' +import { setClipboard } from 'wallet/src/utils/clipboard' +import { ExplorerDataType, getExplorerLink, getProfileUrl, openUri } from 'wallet/src/utils/linking' type MenuAction = { title: string @@ -31,7 +30,7 @@ type MenuAction = { export function ProfileContextMenu({ address }: { address: Address }): JSX.Element { const { t } = useTranslation() - const dispatch = useDispatch() + const dispatch = useAppDispatch() const { unitag } = useUnitagByAddress(address) const onPressCopyAddress = useCallback(async () => { diff --git a/apps/mobile/src/features/externalProfile/ProfileHeader.tsx b/apps/mobile/src/features/externalProfile/ProfileHeader.tsx index 4aa3e5d963c..cf9c9644fd0 100644 --- a/apps/mobile/src/features/externalProfile/ProfileHeader.tsx +++ b/apps/mobile/src/features/externalProfile/ProfileHeader.tsx @@ -3,8 +3,7 @@ import { useTranslation } from 'react-i18next' import { StatusBar, StyleSheet } from 'react-native' import { FadeIn } from 'react-native-reanimated' import Svg, { ClipPath, Defs, RadialGradient, Rect, Stop } from 'react-native-svg' -import { useDispatch } from 'react-redux' -import { useAppSelector } from 'src/app/hooks' +import { useAppDispatch, useAppSelector } from 'src/app/hooks' import { BackButton } from 'src/components/buttons/BackButton' import { Favorite } from 'src/components/icons/Favorite' import { LongText } from 'src/components/text/LongText' @@ -28,14 +27,14 @@ import { SendAction, XTwitter } from 'ui/src/components/icons' import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' import { iconSizes, imageSizes } from 'ui/src/theme' import { ModalName } from 'uniswap/src/features/telemetry/constants' -import { CurrencyField } from 'uniswap/src/features/transactions/transactionState/types' import { TestID } from 'uniswap/src/test/fixtures/testIDs' -import { openUri } from 'uniswap/src/utils/linking' import { AddressDisplay } from 'wallet/src/components/accounts/AddressDisplay' import { useENSDescription, useENSName, useENSTwitterUsername } from 'wallet/src/features/ens/api' import { selectWatchedAddressSet } from 'wallet/src/features/favorites/selectors' +import { CurrencyField } from 'wallet/src/features/transactions/transactionState/types' import { useAvatar, useDisplayName } from 'wallet/src/features/wallet/hooks' import { DisplayNameType } from 'wallet/src/features/wallet/types' +import { openUri } from 'wallet/src/utils/linking' const HEADER_GRADIENT_HEIGHT = 144 const HEADER_ICON_SIZE = 72 @@ -53,7 +52,7 @@ export const solidHeaderProps = { export const ProfileHeader = memo(function ProfileHeader({ address }: ProfileHeaderProps): JSX.Element { const colors = useSporeColors() - const dispatch = useDispatch() + const dispatch = useAppDispatch() const isDarkMode = useIsDarkMode() const isFavorited = useAppSelector(selectWatchedAddressSet).has(address) diff --git a/apps/mobile/src/features/favorites/hooks.ts b/apps/mobile/src/features/favorites/hooks.ts index 84135cebcb7..f5adf8d5cff 100644 --- a/apps/mobile/src/features/favorites/hooks.ts +++ b/apps/mobile/src/features/favorites/hooks.ts @@ -1,6 +1,5 @@ import { useCallback, useMemo } from 'react' -import { useDispatch } from 'react-redux' -import { useAppSelector } from 'src/app/hooks' +import { useAppDispatch, useAppSelector } from 'src/app/hooks' import { MobileEventName } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import { CurrencyId } from 'uniswap/src/types/currency' @@ -16,7 +15,7 @@ import { useCurrencyInfo } from 'wallet/src/features/tokens/useCurrencyInfo' import { useDisplayName } from 'wallet/src/features/wallet/hooks' export function useToggleFavoriteCallback(id: CurrencyId, isFavoriteToken: boolean): () => void { - const dispatch = useDispatch() + const dispatch = useAppDispatch() const token = useCurrencyInfo(id) return useCallback(() => { @@ -35,7 +34,7 @@ export function useToggleFavoriteCallback(id: CurrencyId, isFavoriteToken: boole } export function useToggleWatchedWalletCallback(address: Address): () => void { - const dispatch = useDispatch() + const dispatch = useAppDispatch() const isFavoriteWallet = useAppSelector(selectWatchedAddressSet).has(address) const displayName = useDisplayName(address) diff --git a/apps/mobile/src/features/fiatOnRamp/ExchangeTransferModal.tsx b/apps/mobile/src/features/fiatOnRamp/ExchangeTransferModal.tsx index 9b6144524ca..fea6a94a538 100644 --- a/apps/mobile/src/features/fiatOnRamp/ExchangeTransferModal.tsx +++ b/apps/mobile/src/features/fiatOnRamp/ExchangeTransferModal.tsx @@ -1,5 +1,4 @@ -import { useDispatch } from 'react-redux' -import { useAppSelector } from 'src/app/hooks' +import { useAppDispatch, useAppSelector } from 'src/app/hooks' import { closeModal } from 'src/features/modals/modalSlice' import { selectModalState } from 'src/features/modals/selectModalState' import { ExchangeTransferConnecting } from 'src/screens/ExchangeTransferConnecting' @@ -7,7 +6,7 @@ import { BottomSheetModal } from 'uniswap/src/components/modals/BottomSheetModal import { ModalName } from 'uniswap/src/features/telemetry/constants' export function ExchangeTransferModal(): JSX.Element | null { - const dispatch = useDispatch() + const dispatch = useAppDispatch() const onClose = (): void => { dispatch(closeModal({ name: ModalName.ExchangeTransferModal })) } diff --git a/apps/mobile/src/features/fiatOnRamp/ExchangeTransferServiceProviderSelector.tsx b/apps/mobile/src/features/fiatOnRamp/ExchangeTransferServiceProviderSelector.tsx index 28e22caf474..7602f7aa6e0 100644 --- a/apps/mobile/src/features/fiatOnRamp/ExchangeTransferServiceProviderSelector.tsx +++ b/apps/mobile/src/features/fiatOnRamp/ExchangeTransferServiceProviderSelector.tsx @@ -1,7 +1,7 @@ import React, { useCallback } from 'react' import { FlatList, ListRenderItemInfo } from 'react-native' import { FadeIn, FadeOut } from 'react-native-reanimated' -import { useDispatch } from 'react-redux' +import { useAppDispatch } from 'src/app/hooks' import { openModal } from 'src/features/modals/modalSlice' import { Flex, ImpactFeedbackStyle, Text, TouchableArea, useIsDarkMode } from 'ui/src' import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' @@ -68,7 +68,7 @@ export function ServiceProviderSelector({ onClose: () => void serviceProviders: FORServiceProvider[] }): JSX.Element { - const dispatch = useDispatch() + const dispatch = useAppDispatch() const onSelectServiceProvider = useCallback( (serviceProvider: FORServiceProvider) => { diff --git a/apps/mobile/src/features/fiatOnRamp/FiatOnRampAggregatorModal.tsx b/apps/mobile/src/features/fiatOnRamp/FiatOnRampAggregatorModal.tsx index 70e5b50fde5..c2ca3fed935 100644 --- a/apps/mobile/src/features/fiatOnRamp/FiatOnRampAggregatorModal.tsx +++ b/apps/mobile/src/features/fiatOnRamp/FiatOnRampAggregatorModal.tsx @@ -1,5 +1,5 @@ import React, { useCallback } from 'react' -import { useDispatch } from 'react-redux' +import { useAppDispatch } from 'src/app/hooks' import { FiatOnRampStackNavigator } from 'src/app/navigation/navigation' import { closeModal } from 'src/features/modals/modalSlice' import { useSporeColors } from 'ui/src' @@ -9,7 +9,7 @@ import { ModalName } from 'uniswap/src/features/telemetry/constants' export function FiatOnRampAggregatorModal(): JSX.Element { const colors = useSporeColors() - const dispatch = useDispatch() + const dispatch = useAppDispatch() const onClose = useCallback((): void => { dispatch(closeModal({ name: ModalName.FiatOnRampAggregator })) }, [dispatch]) diff --git a/apps/mobile/src/features/fiatOnRamp/FiatOnRampContext.tsx b/apps/mobile/src/features/fiatOnRamp/FiatOnRampContext.tsx index 1f6ddd701e2..0a61d7d0dd1 100644 --- a/apps/mobile/src/features/fiatOnRamp/FiatOnRampContext.tsx +++ b/apps/mobile/src/features/fiatOnRamp/FiatOnRampContext.tsx @@ -5,7 +5,12 @@ import React, { createContext, useContext, useState } from 'react' import { SectionListData } from 'react-native' import { getCountry } from 'react-native-localize' import { getNativeAddress } from 'uniswap/src/constants/addresses' -import { FORQuote, FiatCurrencyInfo, FiatOnRampCurrency } from 'uniswap/src/features/fiatOnRamp/types' +import { + FORQuote, + FORServiceProvider, + FiatCurrencyInfo, + FiatOnRampCurrency, +} from 'uniswap/src/features/fiatOnRamp/types' import { UniverseChainId } from 'uniswap/src/types/chains' import { buildCurrencyId } from 'uniswap/src/utils/currencyId' import { useCurrencyInfo } from 'wallet/src/features/tokens/useCurrencyInfo' @@ -25,6 +30,8 @@ interface FiatOnRampContextType { setQuoteCurrency: (quoteCurrency: FiatOnRampCurrency) => void amount?: number setAmount: (amount: number | undefined) => void + serviceProviders?: FORServiceProvider[] + setServiceProviders: (serviceProviders: FORServiceProvider[] | undefined) => void } const initialState: FiatOnRampContextType = { @@ -35,6 +42,7 @@ const initialState: FiatOnRampContextType = { setBaseCurrencyInfo: () => undefined, setQuoteCurrency: () => undefined, setAmount: () => undefined, + setServiceProviders: () => undefined, countryCode: '', countryState: undefined, quoteCurrency: { currencyInfo: undefined }, @@ -53,6 +61,7 @@ export function FiatOnRampProvider({ children }: { children: React.ReactNode }): const [countryState, setCountryState] = useState() const [baseCurrencyInfo, setBaseCurrencyInfo] = useState() const [amount, setAmount] = useState() + const [serviceProviders, setServiceProviders] = useState() // We hardcode ETH as the starting currency const ethCurrencyInfo = useCurrencyInfo( @@ -80,6 +89,8 @@ export function FiatOnRampProvider({ children }: { children: React.ReactNode }): setQuoteCurrency, amount, setAmount, + serviceProviders, + setServiceProviders, }} > {children} diff --git a/apps/mobile/src/features/fiatOnRamp/FiatOnRampCountryListModal.tsx b/apps/mobile/src/features/fiatOnRamp/FiatOnRampCountryListModal.tsx index 4e817a492b7..c5aa0109170 100644 --- a/apps/mobile/src/features/fiatOnRamp/FiatOnRampCountryListModal.tsx +++ b/apps/mobile/src/features/fiatOnRamp/FiatOnRampCountryListModal.tsx @@ -1,7 +1,7 @@ import { BottomSheetFlatList } from '@gorhom/bottom-sheet' import React, { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Keyboard, ListRenderItemInfo } from 'react-native' +import { ListRenderItemInfo } from 'react-native' import { FadeIn, FadeOut } from 'react-native-reanimated' import { SvgUri } from 'react-native-svg' import { Loader } from 'src/components/loading' @@ -11,15 +11,15 @@ import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' import { useDeviceDimensions } from 'ui/src/hooks/useDeviceDimensions' import { fonts, iconSizes, spacing } from 'ui/src/theme' import { BottomSheetModal } from 'uniswap/src/components/modals/BottomSheetModal' -import { useBottomSheetFocusHook } from 'uniswap/src/components/modals/hooks' import { useFiatOnRampAggregatorCountryListQuery } from 'uniswap/src/features/fiatOnRamp/api' import { FOR_MODAL_SNAP_POINTS } from 'uniswap/src/features/fiatOnRamp/constants' import { FORCountry } from 'uniswap/src/features/fiatOnRamp/types' import { getCountryFlagSvgUrl } from 'uniswap/src/features/fiatOnRamp/utils' -import { SearchTextInput } from 'uniswap/src/features/search/SearchTextInput' import { ModalName } from 'uniswap/src/features/telemetry/constants' import { bubbleToTop } from 'utilities/src/primitives/array' import { useDebounce } from 'utilities/src/time/timing' +import { useBottomSheetFocusHook } from 'wallet/src/components/modals/hooks' +import { SearchTextInput } from 'wallet/src/features/search/SearchTextInput' const ICON_SIZE = 32 // design prefers a custom value here @@ -86,7 +86,6 @@ function CountrySelectorContent({ onSelectCountry, countryCode }: CountrySelecto py="$spacing8" value={searchText} onChangeText={setSearchText} - onDismiss={() => Keyboard.dismiss()} /> diff --git a/apps/mobile/src/features/fiatOnRamp/FiatOnRampModal.tsx b/apps/mobile/src/features/fiatOnRamp/FiatOnRampModal.tsx new file mode 100644 index 00000000000..6fa2ffc04ae --- /dev/null +++ b/apps/mobile/src/features/fiatOnRamp/FiatOnRampModal.tsx @@ -0,0 +1,320 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { StyleSheet, TextInput } from 'react-native' +import { FadeIn, FadeOut, FadeOutDown } from 'react-native-reanimated' +import { useAppDispatch, useShouldShowNativeKeyboard } from 'src/app/hooks' +import { FiatOnRampCtaButton } from 'src/components/fiatOnRamp/CtaButton' +import { FiatOnRampAmountSection } from 'src/features/fiatOnRamp/FiatOnRampAmountSection' +import { FiatOnRampTokenSelectorModal } from 'src/features/fiatOnRamp/FiatOnRampTokenSelector' +import { useMoonpayFiatOnRamp, useMoonpaySupportedTokens } from 'src/features/fiatOnRamp/hooks' +import { closeModal } from 'src/features/modals/modalSlice' +import { Flex, Text, useDeviceInsets, useSporeColors } from 'ui/src' +import MoonpayLogo from 'ui/src/assets/logos/svg/moonpay.svg' +import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' +import { TextInputProps } from 'uniswap/src/components/input/TextInput' +import { useBottomSheetContext } from 'uniswap/src/components/modals/BottomSheetContext' +import { BottomSheetModal } from 'uniswap/src/components/modals/BottomSheetModal' +import { HandleBar } from 'uniswap/src/components/modals/HandleBar' +import { getNativeAddress } from 'uniswap/src/constants/addresses' +import { FiatOnRampConnectingView } from 'uniswap/src/features/fiatOnRamp/FiatOnRampConnectingView' +import { ServiceProviderLogoStyles } from 'uniswap/src/features/fiatOnRamp/constants' +import { FiatOnRampCurrency } from 'uniswap/src/features/fiatOnRamp/types' +import { FiatOnRampEventName, ModalName } from 'uniswap/src/features/telemetry/constants' +import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' +import { UniverseEventProperties } from 'uniswap/src/features/telemetry/types' +import { UniverseChainId } from 'uniswap/src/types/chains' +import { buildCurrencyId } from 'uniswap/src/utils/currencyId' +import { NumberType } from 'utilities/src/format/types' +import { useTimeout } from 'utilities/src/time/timing' +import { DecimalPadLegacy } from 'wallet/src/components/legacy/DecimalPadLegacy' +import { useLocalFiatToUSDConverter } from 'wallet/src/features/fiatCurrency/hooks' +import { useMoonpayFiatCurrencySupportInfo } from 'wallet/src/features/fiatOnRamp/hooks' +import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' +import { useCurrencyInfo } from 'wallet/src/features/tokens/useCurrencyInfo' +import { openUri } from 'wallet/src/utils/linking' + +const MOONPAY_UNSUPPORTED_REGION_HELP_URL = + 'https://support.uniswap.org/hc/en-us/articles/11306664890381-Why-isn-t-MoonPay-available-in-my-region-' + +const PREDEFINED_AMOUNTS_SUPPORTED_CURRENCIES = ['USD', 'EUR', 'GBP', 'AUD', 'CAD', 'SGD'] + +const CONNECTING_TIMEOUT = 2000 + +export function FiatOnRampModal(): JSX.Element { + const colors = useSporeColors() + + const dispatch = useAppDispatch() + const onClose = useCallback((): void => { + dispatch(closeModal({ name: ModalName.FiatOnRamp })) + }, [dispatch]) + + return ( + + + + ) +} + +function FiatOnRampContent({ onClose }: { onClose: () => void }): JSX.Element { + const { t } = useTranslation() + const { formatNumberOrString } = useLocalizationContext() + const inputRef = useRef(null) + + const { isSheetReady } = useBottomSheetContext() + + const [showConnectingToMoonpayScreen, setShowConnectingToMoonpayScreen] = useState(false) + + const { showNativeKeyboard, onDecimalPadLayout, isLayoutPending, onInputPanelLayout } = useShouldShowNativeKeyboard() + + const [selection, setSelection] = useState() + + const resetSelection = (start: number, end?: number): void => { + setSelection({ start, end: end ?? start }) + } + + const [value, setValue] = useState('') + + // We hardcode ETH as the starting currency + const ethCurrencyInfo = useCurrencyInfo( + buildCurrencyId(UniverseChainId.Mainnet, getNativeAddress(UniverseChainId.Mainnet)), + ) + + const [currency, setCurrency] = useState({ + currencyInfo: ethCurrencyInfo, + moonpayCurrencyCode: 'eth', + }) + + const { appFiatCurrencySupportedInMoonpay, moonpaySupportedFiatCurrency } = useMoonpayFiatCurrencySupportInfo() + + // We only support predefined amounts for certain currencies. + // If the user's app fiat currency is not supported in Moonpay, + // we fallback to USD (which does allow for predefined amounts) + const predefinedAmountsSupported = + PREDEFINED_AMOUNTS_SUPPORTED_CURRENCIES.includes(moonpaySupportedFiatCurrency.code) || + !appFiatCurrencySupportedInMoonpay + + // We might not have ethCurrencyInfo when this component is initially rendered. + // If `ethCurrencyInfo` becomes available later while currency.currencyInfo is still unset, we update the currency state accordingly. + useEffect(() => { + if (ethCurrencyInfo && !currency.currencyInfo) { + setCurrency({ ...currency, currencyInfo: ethCurrencyInfo }) + } + }, [currency, currency.currencyInfo, ethCurrencyInfo]) + + const { + eligible, + quoteAmount, + isLoading, + isError, + externalTransactionId, + dispatchAddTransaction, + fiatOnRampHostUrl, + quoteCurrencyAmountReady, + quoteCurrencyAmountLoading, + errorText, + errorColor, + } = useMoonpayFiatOnRamp({ + baseCurrencyAmount: value, + quoteCurrencyCode: currency.moonpayCurrencyCode, + quoteChainId: currency.currencyInfo?.currency.chainId ?? UniverseChainId.Mainnet, + }) + + useTimeout( + async () => { + if (fiatOnRampHostUrl) { + if (currency?.moonpayCurrencyCode) { + sendAnalyticsEvent(FiatOnRampEventName.FiatOnRampWidgetOpened, { + externalTransactionId, + serviceProvider: 'MOONPAY', + fiatCurrency: moonpaySupportedFiatCurrency.code.toLowerCase(), + cryptoCurrency: currency.moonpayCurrencyCode.toLowerCase(), + }) + } + await openUri(fiatOnRampHostUrl) + dispatchAddTransaction() + onClose() + } + }, + // setTimeout would be called inside this hook, only when delay >= 0 + showConnectingToMoonpayScreen ? CONNECTING_TIMEOUT : -1, + ) + + const buttonEnabled = !isLoading && (!eligible || (!isError && fiatOnRampHostUrl && quoteCurrencyAmountReady)) + + const fiatToUSDConverter = useLocalFiatToUSDConverter() + + const onChangeValue = + (source: UniverseEventProperties[FiatOnRampEventName.FiatOnRampAmountEntered]['source']) => + (newAmount: string): void => { + sendAnalyticsEvent(FiatOnRampEventName.FiatOnRampAmountEntered, { + source, + amountUSD: fiatToUSDConverter(parseFloat(newAmount)), + }) + setValue(newAmount) + } + + const [showTokenSelector, setShowTokenSelector] = useState(false) + + useEffect(() => { + if (showTokenSelector) { + // hide keyboard when user goes to token selector screen + inputRef.current?.blur() + } else if (showNativeKeyboard && eligible) { + // autofocus + inputRef.current?.focus() + } + }, [showNativeKeyboard, eligible, showTokenSelector]) + + const selectTokenLoading = quoteCurrencyAmountLoading && !errorText && !!value + + const { + list: supportedTokensList, + loading: supportedTokensLoading, + error: supportedTokensError, + refetch: supportedTokensRefetch, + } = useMoonpaySupportedTokens() + + const insets = useDeviceInsets() + + const onSelectCurrency = (newCurrency: FiatOnRampCurrency): void => { + setCurrency(newCurrency) + setShowTokenSelector(false) + if (newCurrency.currencyInfo?.currency.symbol) { + sendAnalyticsEvent(FiatOnRampEventName.FiatOnRampTokenSelected, { + token: newCurrency.currencyInfo.currency.symbol.toLowerCase(), + }) + } + } + + return ( + + {!showConnectingToMoonpayScreen && ( + + {isSheetReady && ( + + + {t('common.button.buy')} + { + setShowTokenSelector(true) + }} + /> + + {!showNativeKeyboard && ( + + )} + => { + if (eligible) { + setShowConnectingToMoonpayScreen(true) + } else { + await openUri(MOONPAY_UNSUPPORTED_REGION_HELP_URL) + } + }} + /> + + + )} + {showTokenSelector && ( + setShowTokenSelector(false)} + onRetry={supportedTokensRefetch} + onSelectCurrency={onSelectCurrency} + /> + )} + + )} + {showConnectingToMoonpayScreen && ( + + + + } + serviceProviderName="MoonPay" + /> + )} + + ) +} + +const styles = StyleSheet.create({ + moonpayLogoWrapper: { + backgroundColor: '#7D00FF', + }, +}) diff --git a/apps/mobile/src/features/fiatOnRamp/aggregatorHooks.ts b/apps/mobile/src/features/fiatOnRamp/aggregatorHooks.ts new file mode 100644 index 00000000000..3f0e5e090f8 --- /dev/null +++ b/apps/mobile/src/features/fiatOnRamp/aggregatorHooks.ts @@ -0,0 +1,145 @@ +import { SerializedError } from '@reduxjs/toolkit' +import { FetchBaseQueryError, skipToken } from '@reduxjs/toolkit/query/react' +import { useTranslation } from 'react-i18next' +import { Delay } from 'src/components/layout/Delayed' +import { ColorTokens } from 'ui/src' +import { + useFiatOnRampAggregatorCryptoQuoteQuery, + useFiatOnRampAggregatorSupportedFiatCurrenciesQuery, +} from 'uniswap/src/features/fiatOnRamp/api' +import { FORQuote, FORSupportedFiatCurrency, FiatCurrencyInfo } from 'uniswap/src/features/fiatOnRamp/types' +import { + isFiatOnRampApiError, + isInvalidRequestAmountTooHigh, + isInvalidRequestAmountTooLow, +} from 'uniswap/src/features/fiatOnRamp/utils' +import { NumberType } from 'utilities/src/format/types' +import { useDebounce } from 'utilities/src/time/timing' +import { FiatCurrency } from 'wallet/src/features/fiatCurrency/constants' +import { useAppFiatCurrencyInfo, useFiatCurrencyInfo } from 'wallet/src/features/fiatCurrency/hooks' +import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' +import { useActiveAccountAddress } from 'wallet/src/features/wallet/hooks' + +export function useMeldFiatCurrencySupportInfo(countryCode: string): { + appFiatCurrencySupportedInMeld: boolean + meldSupportedFiatCurrency: FiatCurrencyInfo + supportedFiatCurrencies: FORSupportedFiatCurrency[] | undefined +} { + // Not all the currencies are supported by Meld, so we need to fallback to USD if the currency is not supported + const appFiatCurrencyInfo = useAppFiatCurrencyInfo() + const fallbackCurrencyInfo = useFiatCurrencyInfo(FiatCurrency.UnitedStatesDollar) + const appFiatCurrencyCode = appFiatCurrencyInfo.code.toLowerCase() + + const { data: supportedFiatCurrencies } = useFiatOnRampAggregatorSupportedFiatCurrenciesQuery({ + countryCode, + }) + + const appFiatCurrencySupported = + !supportedFiatCurrencies || + supportedFiatCurrencies.fiatCurrencies.some( + (currency): boolean => appFiatCurrencyCode === currency.fiatCurrencyCode.toLowerCase(), + ) + const meldSupportedFiatCurrency = appFiatCurrencySupported ? appFiatCurrencyInfo : fallbackCurrencyInfo + + return { + appFiatCurrencySupportedInMeld: appFiatCurrencySupported, + meldSupportedFiatCurrency, + supportedFiatCurrencies: supportedFiatCurrencies?.fiatCurrencies, + } +} + +/** + * Hook to load quotes + */ +export function useFiatOnRampQuotes({ + baseCurrencyAmount, + baseCurrencyCode, + quoteCurrencyCode, + countryCode, + countryState, +}: { + baseCurrencyAmount?: number + baseCurrencyCode: string | undefined + quoteCurrencyCode: string | undefined + countryCode: string | undefined + countryState: string | undefined +}): { + loading: boolean + error?: FetchBaseQueryError | SerializedError + quotes: FORQuote[] | undefined +} { + const debouncedBaseCurrencyAmount = useDebounce(baseCurrencyAmount, Delay.Short) + const walletAddress = useActiveAccountAddress() + + const { + currentData: quotesResponse, + isFetching: quotesFetching, + error: quotesError, + } = useFiatOnRampAggregatorCryptoQuoteQuery( + baseCurrencyAmount && countryCode && quoteCurrencyCode && baseCurrencyCode + ? { + sourceAmount: baseCurrencyAmount, + sourceCurrencyCode: baseCurrencyCode, + destinationCurrencyCode: quoteCurrencyCode, + countryCode, + walletAddress: walletAddress ?? '', + state: countryState, + } + : skipToken, + { + refetchOnMountOrArgChange: true, + }, + ) + + const loading = quotesFetching || debouncedBaseCurrencyAmount !== baseCurrencyAmount + + // if user is entering base amount -> ignore previous errors + const error = debouncedBaseCurrencyAmount !== baseCurrencyAmount ? undefined : quotesError + + return { + loading, + error, + quotes: quotesResponse?.quotes ?? undefined, + } +} + +export function useParseFiatOnRampError( + error: unknown, + currencyCode: string, +): { + errorText: string | undefined + errorColor: ColorTokens | undefined +} { + const { t } = useTranslation() + const { formatNumberOrString } = useLocalizationContext() + + let errorText, errorColor: ColorTokens | undefined + if (!error) { + return { errorText, errorColor } + } + + errorText = t('fiatOnRamp.error.default') + errorColor = '$DEP_accentWarning' + + if (isFiatOnRampApiError(error)) { + if (isInvalidRequestAmountTooLow(error)) { + const formattedAmount = formatNumberOrString({ + value: error.data.context.minimumAllowed, + type: NumberType.FiatStandard, + currencyCode, + }) + errorText = t('fiatOnRamp.error.min', { amount: formattedAmount }) + errorColor = '$statusCritical' + } else if (isInvalidRequestAmountTooHigh(error)) { + const formattedAmount = formatNumberOrString({ + value: error.data.context.maximumAllowed, + type: NumberType.FiatStandard, + currencyCode, + }) + errorText = t('fiatOnRamp.error.max', { amount: formattedAmount }) + errorColor = '$statusCritical' + } + } + + return { errorText, errorColor } +} diff --git a/apps/mobile/src/features/fiatOnRamp/hooks.ts b/apps/mobile/src/features/fiatOnRamp/hooks.ts index f57fbf211c8..4e95e7d8a3b 100644 --- a/apps/mobile/src/features/fiatOnRamp/hooks.ts +++ b/apps/mobile/src/features/fiatOnRamp/hooks.ts @@ -1,39 +1,32 @@ -import { SerializedError } from '@reduxjs/toolkit' -import { FetchBaseQueryError, skipToken } from '@reduxjs/toolkit/query/react' +import { skipToken } from '@reduxjs/toolkit/query/react' import { Currency } from '@uniswap/sdk-core' import { useCallback, useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' -import { useDispatch } from 'react-redux' +import { useAppDispatch } from 'src/app/hooks' import { Delay } from 'src/components/layout/Delayed' -import { ColorTokens } from 'ui/src' -import { toSupportedChainId } from 'uniswap/src/features/chains/utils' +import { ColorTokens, useSporeColors } from 'ui/src' +import { BRIDGED_BASE_ADDRESSES } from 'uniswap/src/constants/addresses' +import { uniswapUrls } from 'uniswap/src/constants/urls' +import { fromMoonpayNetwork, toSupportedChainId } from 'uniswap/src/features/chains/utils' import { CurrencyInfo } from 'uniswap/src/features/dataApi/types' -import { - useFiatOnRampAggregatorCryptoQuoteQuery, - useFiatOnRampAggregatorSupportedFiatCurrenciesQuery, - useFiatOnRampAggregatorSupportedTokensQuery, -} from 'uniswap/src/features/fiatOnRamp/api' -import { - FORQuote, - FORSupportedFiatCurrency, - FORSupportedToken, - FiatCurrencyInfo, - FiatOnRampCurrency, -} from 'uniswap/src/features/fiatOnRamp/types' -import { - createOnRampTransactionId, - isFiatOnRampApiError, - isInvalidRequestAmountTooHigh, - isInvalidRequestAmountTooLow, -} from 'uniswap/src/features/fiatOnRamp/utils' +import { useFiatOnRampAggregatorSupportedTokensQuery } from 'uniswap/src/features/fiatOnRamp/api' +import { FORSupportedToken, FiatOnRampCurrency } from 'uniswap/src/features/fiatOnRamp/types' import { WalletChainId } from 'uniswap/src/types/chains' +import { areAddressesEqual } from 'uniswap/src/utils/addresses' import { buildCurrencyId, buildNativeCurrencyId } from 'uniswap/src/utils/currencyId' -import { NumberType } from 'utilities/src/format/types' +import { logger } from 'utilities/src/logger/logger' import { useDebounce } from 'utilities/src/time/timing' -import { useCurrencies } from 'wallet/src/components/TokenSelector/hooks' +import { useAllCommonBaseCurrencies, useCurrencies } from 'wallet/src/components/TokenSelector/hooks' import { Routing } from 'wallet/src/data/tradingApi/__generated__/index' -import { FiatCurrency } from 'wallet/src/features/fiatCurrency/constants' -import { useAppFiatCurrencyInfo, useFiatCurrencyInfo } from 'wallet/src/features/fiatCurrency/hooks' +import { + useFiatOnRampBuyQuoteQuery, + useFiatOnRampIpAddressQuery, + useFiatOnRampLimitsQuery, + useFiatOnRampSupportedTokensQuery, + useFiatOnRampWidgetUrlQuery, +} from 'wallet/src/features/fiatOnRamp/api' +import { useMoonpayFiatCurrencySupportInfo } from 'wallet/src/features/fiatOnRamp/hooks' +import { MoonpayCurrency } from 'wallet/src/features/fiatOnRamp/types' import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' import { addTransaction } from 'wallet/src/features/transactions/slice' import { @@ -42,10 +35,15 @@ import { TransactionStatus, TransactionType, } from 'wallet/src/features/transactions/types' -import { useActiveAccountAddress } from 'wallet/src/features/wallet/hooks' +import { createTransactionId } from 'wallet/src/features/transactions/utils' +import { useActiveAccountAddressWithThrow } from 'wallet/src/features/wallet/hooks' import { getFormattedCurrencyAmount } from 'wallet/src/utils/currency' import { ValueType } from 'wallet/src/utils/getCurrencyAmount' +const ETH_POLYGON_MOONPAY_CODE = 'eth_polygon' +const WETH_POLYGON_MOONPAY_CODE = 'weth_polygon' +const BNB_MAINNET_MOONPAY_CODE = 'bnb' + export function useFormatExactCurrencyAmount(currencyAmount: string, currency: Maybe): string | undefined { const formatter = useLocalizationContext() @@ -63,15 +61,14 @@ export function useFormatExactCurrencyAmount(currencyAmount: string, currency: M export function useFiatOnRampTransactionCreator( ownerAddress: string, chainId: WalletChainId, - serviceProvider?: string, initialTypeInfo?: Partial, ): { externalTransactionId: string dispatchAddTransaction: () => void } { - const dispatch = useDispatch() + const dispatch = useAppDispatch() - const externalTransactionId = useRef(createOnRampTransactionId(serviceProvider)) + const externalTransactionId = useRef(createTransactionId()) const dispatchAddTransaction = useCallback(() => { // adds a dummy transaction detail for now @@ -98,32 +95,195 @@ export function useFiatOnRampTransactionCreator( return { externalTransactionId: externalTransactionId.current, dispatchAddTransaction } } -export function useMeldFiatCurrencySupportInfo(countryCode: string): { - appFiatCurrencySupportedInMeld: boolean - meldSupportedFiatCurrency: FiatCurrencyInfo - supportedFiatCurrencies: FORSupportedFiatCurrency[] | undefined +const MOONPAY_FEES_INCLUDED = true + +/** + * Hook to provide data from Moonpay for Fiat On Ramp Input Amount screen. + */ +export function useMoonpayFiatOnRamp({ + baseCurrencyAmount, + quoteCurrencyCode, + quoteChainId, +}: { + baseCurrencyAmount: string + quoteCurrencyCode: string | undefined + quoteChainId: WalletChainId +}): { + eligible: boolean + quoteAmount: number + quoteCurrencyAmountReady: boolean + quoteCurrencyAmountLoading: boolean + isLoading: boolean + externalTransactionId: string + dispatchAddTransaction: () => void + fiatOnRampHostUrl?: string + isError: boolean + errorText?: string + errorColor?: ColorTokens } { - // Not all the currencies are supported by Meld, so we need to fallback to USD if the currency is not supported - const appFiatCurrencyInfo = useAppFiatCurrencyInfo() - const fallbackCurrencyInfo = useFiatCurrencyInfo(FiatCurrency.UnitedStatesDollar) - const appFiatCurrencyCode = appFiatCurrencyInfo.code.toLowerCase() + const colors = useSporeColors() + + const debouncedBaseCurrencyAmount = useDebounce(baseCurrencyAmount, Delay.Short) + + // we can consider adding `ownerAddress` as a prop to this modal in the future + // for now, always assume the user wants to fund the current account + const activeAccountAddress = useActiveAccountAddressWithThrow() + + const { externalTransactionId, dispatchAddTransaction } = useFiatOnRampTransactionCreator( + activeAccountAddress, + quoteChainId, + ) + + const { moonpaySupportedFiatCurrency: baseCurrency } = useMoonpayFiatCurrencySupportInfo() + const baseCurrencyCode = baseCurrency.code.toLowerCase() + const baseCurrencySymbol = baseCurrency.symbol + + const { + data: limitsData, + isLoading: limitsLoading, + isError: limitsLoadingQueryError, + } = useFiatOnRampLimitsQuery( + quoteCurrencyCode + ? { + baseCurrencyCode, + quoteCurrencyCode, + areFeesIncluded: MOONPAY_FEES_INCLUDED, + } + : skipToken, + ) + + const { maxBuyAmount } = limitsData?.baseCurrency ?? { + maxBuyAmount: Infinity, + } + + // we're adding +1 here because MoonPay API is not precise with limits + // and an actual lower limit is a bit above the number, they provide in limits api + const minBuyAmount = limitsData?.baseCurrency?.minBuyAmount ? limitsData.baseCurrency.minBuyAmount + 1 : 0 + + const parsedBaseCurrencyAmount = parseFloat(baseCurrencyAmount) + const amountIsTooSmall = parsedBaseCurrencyAmount < minBuyAmount + const amountIsTooLarge = parsedBaseCurrencyAmount > maxBuyAmount + const isBaseCurrencyAmountValid = !!parsedBaseCurrencyAmount && !amountIsTooSmall && !amountIsTooLarge + + const { + data: fiatOnRampHostUrl, + isError: isWidgetUrlQueryError, + isLoading: isWidgetUrlLoading, + } = useFiatOnRampWidgetUrlQuery( + // PERF: could consider skipping this call until eligibility in determined (ux tradeoffs) + // as-is, avoids waterfalling requests => better ux + quoteCurrencyCode + ? { + ownerAddress: activeAccountAddress, + colorCode: colors.accent1.val, + externalTransactionId, + amount: baseCurrencyAmount, + currencyCode: quoteCurrencyCode, + baseCurrencyCode, + redirectUrl: `${uniswapUrls.redirectUrlBase}/?screen=transaction&fiatOnRamp=true&userAddress=${activeAccountAddress}`, + } + : skipToken, + ) + const { + data: buyQuote, + isFetching: buyQuoteLoading, + isError: buyQuoteLoadingQueryError, + } = useFiatOnRampBuyQuoteQuery( + // When isBaseCurrencyAmountValid is false and the user enters any digit, + // isBaseCurrencyAmountValid becomes true. Since there were no prior calls to the API, + // it takes the debouncedBaseCurrencyAmount and immediately calls an API. + // This only truly matters in the beginning and in cases where the debouncedBaseCurrencyAmount + // is changed while isBaseCurrencyAmountValid is false." + quoteCurrencyCode && isBaseCurrencyAmountValid && debouncedBaseCurrencyAmount === baseCurrencyAmount + ? { + baseCurrencyCode, + baseCurrencyAmount: debouncedBaseCurrencyAmount, + quoteCurrencyCode, + areFeesIncluded: MOONPAY_FEES_INCLUDED, + } + : skipToken, + ) - const { data: supportedFiatCurrencies } = useFiatOnRampAggregatorSupportedFiatCurrenciesQuery({ - countryCode, + const quoteAmount = buyQuote?.quoteCurrencyAmount ?? 0 + + const { + data: ipAddressData, + isLoading: isEligibleLoading, + isError: isFiatBuyAllowedQueryError, + } = useFiatOnRampIpAddressQuery() + + const eligible = Boolean(ipAddressData?.isBuyAllowed) + + const isLoading = isEligibleLoading || isWidgetUrlLoading + const isError = + isFiatBuyAllowedQueryError || isWidgetUrlQueryError || buyQuoteLoadingQueryError || limitsLoadingQueryError + + const quoteCurrencyAmountLoading = + buyQuoteLoading || limitsLoading || debouncedBaseCurrencyAmount !== baseCurrencyAmount + + const quoteCurrencyAmountReady = isBaseCurrencyAmountValid && !quoteCurrencyAmountLoading + + const { addFiatSymbolToNumber } = useLocalizationContext() + const minBuyAmountWithFiatSymbol = addFiatSymbolToNumber({ + value: minBuyAmount, + currencyCode: baseCurrencyCode, + currencySymbol: baseCurrencySymbol, + }) + const maxBuyAmountWithFiatSymbol = addFiatSymbolToNumber({ + value: maxBuyAmount, + currencyCode: baseCurrencyCode, + currencySymbol: baseCurrencySymbol, }) - const appFiatCurrencySupported = - !supportedFiatCurrencies || - supportedFiatCurrencies.fiatCurrencies.some( - (currency): boolean => appFiatCurrencyCode === currency.fiatCurrencyCode.toLowerCase(), - ) - const meldSupportedFiatCurrency = appFiatCurrencySupported ? appFiatCurrencyInfo : fallbackCurrencyInfo + const { errorText, errorColor } = useMoonpayError( + isError, + amountIsTooSmall, + amountIsTooLarge, + minBuyAmountWithFiatSymbol, + maxBuyAmountWithFiatSymbol, + ) return { - appFiatCurrencySupportedInMeld: appFiatCurrencySupported, - meldSupportedFiatCurrency, - supportedFiatCurrencies: supportedFiatCurrencies?.fiatCurrencies, + eligible, + quoteAmount, + quoteCurrencyAmountReady, + quoteCurrencyAmountLoading, + isLoading, + externalTransactionId, + dispatchAddTransaction, + fiatOnRampHostUrl, + isError, + errorText, + errorColor, + } +} + +function useMoonpayError( + hasError: boolean, + amountIsTooSmall: boolean, + amountIsTooLarge: boolean, + minBuyAmountWithFiatSymbol: string, + maxBuyAmountWithFiatSymbol: string, +): { + errorText: string | undefined + errorColor: ColorTokens | undefined +} { + const { t } = useTranslation() + + let errorText, errorColor: ColorTokens | undefined + + if (hasError) { + errorText = t('fiatOnRamp.error.default') + errorColor = '$DEP_accentWarning' + } else if (amountIsTooSmall) { + errorText = t('fiatOnRamp.error.min', { amount: minBuyAmountWithFiatSymbol }) + errorColor = '$statusCritical' + } else if (amountIsTooLarge) { + errorText = t('fiatOnRamp.error.max', { amount: maxBuyAmountWithFiatSymbol }) + errorColor = '$statusCritical' } + + return { errorText, errorColor } } function findTokenOptionForFiatOnRampToken( @@ -141,6 +301,42 @@ function findTokenOptionForFiatOnRampToken( }) } +function findTokenOptionForMoonpayCurrency( + commonBaseCurrencies: CurrencyInfo[] | undefined = [], + moonpayCurrency: MoonpayCurrency, +): Maybe { + const currencyInfo = commonBaseCurrencies.find((item) => { + // Moonpay uses WETH on Polygon to represent ETH on Polygon + const moonpayCurrencyCode = + moonpayCurrency.code === ETH_POLYGON_MOONPAY_CODE ? WETH_POLYGON_MOONPAY_CODE : moonpayCurrency.code + const [tokenSymbol, network] = moonpayCurrencyCode.split('_') + const chainId = fromMoonpayNetwork(network) + return ( + item && + tokenSymbol && + tokenSymbol.toLowerCase() === item.currency.symbol?.toLowerCase() && + chainId === item.currency.chainId + ) + }) + if ( + !currencyInfo && + !BRIDGED_BASE_ADDRESSES.find((bridgedAddress) => + areAddressesEqual(bridgedAddress, moonpayCurrency.metadata?.contractAddress), + ) && + // We do not support BNB onboarding and Moonpay does not return an address for it so map it manually + moonpayCurrency.code !== BNB_MAINNET_MOONPAY_CODE + ) { + logger.error(`Moonpay currency ${moonpayCurrency.code} cannot be mapped`, { + tags: { file: 'fiatOnRamp/hooks', function: 'useMoonpaySupportedTokens' }, + extra: { + chainId: moonpayCurrency.metadata?.chainId, + address: moonpayCurrency.metadata?.contractAddress, + }, + }) + } + return currencyInfo +} + function buildCurrencyIdForFORSupportedToken(supportedToken: FORSupportedToken): string | undefined { const chainId = toSupportedChainId(supportedToken.chainId) return chainId @@ -209,98 +405,66 @@ export function useFiatOnRampSupportedTokens({ return { list, loading, error, refetch } } -/** - * Hook to load quotes - */ -export function useFiatOnRampQuotes({ - baseCurrencyAmount, - baseCurrencyCode, - quoteCurrencyCode, - countryCode, - countryState, -}: { - baseCurrencyAmount?: number - baseCurrencyCode: string | undefined - quoteCurrencyCode: string | undefined - countryCode: string | undefined - countryState: string | undefined -}): { +export function useMoonpaySupportedTokens(): { + error: boolean + list: FiatOnRampCurrency[] | undefined loading: boolean - error?: FetchBaseQueryError | SerializedError - quotes: FORQuote[] | undefined + refetch: () => void } { - const debouncedBaseCurrencyAmount = useDebounce(baseCurrencyAmount, Delay.Short) - const walletAddress = useActiveAccountAddress() + // this should be already cached by the time we need it + const { + data: ipAddressData, + isLoading: ipAddressLoading, + isError: ipAddressError, + refetch: refetchIpAddress, + } = useFiatOnRampIpAddressQuery() const { - currentData: quotesResponse, - isFetching: quotesFetching, - error: quotesError, - } = useFiatOnRampAggregatorCryptoQuoteQuery( - baseCurrencyAmount && countryCode && quoteCurrencyCode && baseCurrencyCode - ? { - sourceAmount: baseCurrencyAmount, - sourceCurrencyCode: baseCurrencyCode, - destinationCurrencyCode: quoteCurrencyCode, - countryCode, - walletAddress: walletAddress ?? undefined, - state: countryState, - } - : skipToken, + data: supportedTokens, + isLoading: supportedTokensLoading, + isError: supportedTokensError, + refetch: refetchSupportedTokens, + } = useFiatOnRampSupportedTokensQuery( { - refetchOnMountOrArgChange: true, + isUserInUS: ipAddressData?.alpha3 === 'USA' ?? false, + stateInUS: ipAddressData?.state, }, + { skip: !ipAddressData }, ) - const loading = quotesFetching || debouncedBaseCurrencyAmount !== baseCurrencyAmount - - // if user is entering base amount -> ignore previous errors - const error = debouncedBaseCurrencyAmount !== baseCurrencyAmount ? undefined : quotesError - - return { - loading, - error, - quotes: quotesResponse?.quotes ?? undefined, - } -} + const { + data: commonBaseCurrencies, + error: commonBaseCurrenciesError, + loading: commonBaseCurrenciesLoading, + refetch: refetchCommonBaseCurrencies, + } = useAllCommonBaseCurrencies() -export function useParseFiatOnRampError( - error: unknown, - currencyCode: string, -): { - errorText: string | undefined - errorColor: ColorTokens | undefined -} { - const { t } = useTranslation() - const { formatNumberOrString } = useLocalizationContext() + const list = useMemo(() => { + if (!commonBaseCurrencies || !supportedTokens) { + return undefined + } - let errorText, errorColor: ColorTokens | undefined - if (!error) { - return { errorText, errorColor } - } + return supportedTokens + .map((fiatOnRampToken) => ({ + currencyInfo: findTokenOptionForMoonpayCurrency(commonBaseCurrencies, fiatOnRampToken), + moonpayCurrencyCode: fiatOnRampToken.code, + })) + .filter((item) => !!item.currencyInfo) + }, [commonBaseCurrencies, supportedTokens]) - errorText = t('fiatOnRamp.error.default') - errorColor = '$DEP_accentWarning' - - if (isFiatOnRampApiError(error)) { - if (isInvalidRequestAmountTooLow(error)) { - const formattedAmount = formatNumberOrString({ - value: error.data.context.minimumAllowed, - type: NumberType.FiatStandard, - currencyCode, - }) - errorText = t('fiatOnRamp.error.min', { amount: formattedAmount }) - errorColor = '$statusCritical' - } else if (isInvalidRequestAmountTooHigh(error)) { - const formattedAmount = formatNumberOrString({ - value: error.data.context.maximumAllowed, - type: NumberType.FiatStandard, - currencyCode, - }) - errorText = t('fiatOnRamp.error.max', { amount: formattedAmount }) - errorColor = '$statusCritical' + const loading = ipAddressLoading || supportedTokensLoading || commonBaseCurrenciesLoading + const error = Boolean(ipAddressError || supportedTokensError || commonBaseCurrenciesError) + const refetch = async (): Promise => { + if (ipAddressError) { + await refetchIpAddress() + } + if (supportedTokensError) { + await refetchSupportedTokens() + } + if (commonBaseCurrenciesError) { + refetchCommonBaseCurrencies?.() } } - return { errorText, errorColor } + return { list, loading, error, refetch } } diff --git a/apps/mobile/src/features/import/GenericImportForm.tsx b/apps/mobile/src/features/import/GenericImportForm.tsx index ecad2210b2e..f0bbcac38cf 100644 --- a/apps/mobile/src/features/import/GenericImportForm.tsx +++ b/apps/mobile/src/features/import/GenericImportForm.tsx @@ -3,9 +3,9 @@ import { Keyboard, TextInput as NativeTextInput } from 'react-native' import InputWithSuffix from 'src/features/import/InputWithSuffix' import { Flex, Text, useMedia } from 'ui/src' import { fonts } from 'ui/src/theme' -import PasteButton from 'uniswap/src/components/buttons/PasteButton' import Trace from 'uniswap/src/features/telemetry/Trace' import { SectionName } from 'uniswap/src/features/telemetry/constants' +import PasteButton from 'wallet/src/components/buttons/PasteButton' interface Props { value: string | undefined diff --git a/apps/mobile/src/features/modals/ModalsState.ts b/apps/mobile/src/features/modals/ModalsState.ts index f058dd00980..631020b404b 100644 --- a/apps/mobile/src/features/modals/ModalsState.ts +++ b/apps/mobile/src/features/modals/ModalsState.ts @@ -4,9 +4,9 @@ import { ScantasticModalState } from 'src/features/scantastic/ScantasticModalSta import { ReceiveCryptoModalState } from 'src/screens/ReceiveCryptoModalState' import { FORServiceProvider } from 'uniswap/src/features/fiatOnRamp/types' import { ModalName } from 'uniswap/src/features/telemetry/constants' -import { TransactionState } from 'uniswap/src/features/transactions/transactionState/types' import { MobileScreens } from 'uniswap/src/types/screens/mobile' import { ScannerModalState } from 'wallet/src/components/QRCodeScanner/constants' +import { TransactionState } from 'wallet/src/features/transactions/transactionState/types' export interface AppModalState { isOpen: boolean @@ -21,6 +21,7 @@ export interface ModalsState { [ModalName.Experiments]: AppModalState [ModalName.Explore]: AppModalState [ModalName.FiatCurrencySelector]: AppModalState + [ModalName.FiatOnRamp]: AppModalState [ModalName.FiatOnRampAggregator]: AppModalState [ModalName.ReceiveCryptoModal]: AppModalState [ModalName.LanguageSelector]: AppModalState diff --git a/apps/mobile/src/features/modals/modalSlice.test.ts b/apps/mobile/src/features/modals/modalSlice.test.ts index 5bd984a145a..ae3f49dd86c 100644 --- a/apps/mobile/src/features/modals/modalSlice.test.ts +++ b/apps/mobile/src/features/modals/modalSlice.test.ts @@ -1,5 +1,10 @@ import { createStore, Store } from '@reduxjs/toolkit' -import { closeModal, initialModalsState, modalsReducer, openModal } from 'src/features/modals/modalSlice' +import { + closeModal, + initialModalsState, + modalsReducer, + openModal, +} from 'src/features/modals/modalSlice' import { ModalsState } from 'src/features/modals/ModalsState' import { ModalName } from 'uniswap/src/features/telemetry/constants' import { ScannerModalState } from 'wallet/src/components/QRCodeScanner/constants' diff --git a/apps/mobile/src/features/modals/modalSlice.ts b/apps/mobile/src/features/modals/modalSlice.ts index 239333724e4..a3bd0aeb0bb 100644 --- a/apps/mobile/src/features/modals/modalSlice.ts +++ b/apps/mobile/src/features/modals/modalSlice.ts @@ -6,10 +6,10 @@ import { ModalsState } from 'src/features/modals/ModalsState' import { ScantasticModalState } from 'src/features/scantastic/ScantasticModalState' import { ReceiveCryptoModalState } from 'src/screens/ReceiveCryptoModalState' import { ModalName } from 'uniswap/src/features/telemetry/constants' -import { TransactionState } from 'uniswap/src/features/transactions/transactionState/types' import { MobileScreens } from 'uniswap/src/types/screens/mobile' import { getKeys } from 'utilities/src/primitives/objects' import { ScannerModalState } from 'wallet/src/components/QRCodeScanner/constants' +import { TransactionState } from 'wallet/src/features/transactions/transactionState/types' type AccountSwitcherModalParams = { name: typeof ModalName.AccountSwitcher @@ -33,6 +33,8 @@ type FiatCurrencySelectorParams = { initialState?: undefined } +type FiatOnRampModalParams = { name: typeof ModalName.FiatOnRamp; initialState?: undefined } + type FiatOnRampAggregatorModalParams = { name: typeof ModalName.FiatOnRampAggregator initialState?: undefined @@ -85,6 +87,7 @@ export type OpenModalParams = | ExperimentsModalParams | ExploreModalParams | FiatCurrencySelectorParams + | FiatOnRampModalParams | FiatOnRampAggregatorModalParams | ReceiveCryptoModalParams | LanguageSelectorModalParams @@ -104,6 +107,10 @@ export const initialModalsState: ModalsState = { isOpen: false, initialState: undefined, }, + [ModalName.FiatOnRamp]: { + isOpen: false, + initialState: undefined, + }, [ModalName.FiatOnRampAggregator]: { isOpen: false, initialState: undefined, diff --git a/apps/mobile/src/features/nfts/collection/NFTCollectionContextMenu.tsx b/apps/mobile/src/features/nfts/collection/NFTCollectionContextMenu.tsx index 8a83ff92dae..fac0a475acd 100644 --- a/apps/mobile/src/features/nfts/collection/NFTCollectionContextMenu.tsx +++ b/apps/mobile/src/features/nfts/collection/NFTCollectionContextMenu.tsx @@ -10,9 +10,8 @@ import { iconSizes, spacing } from 'ui/src/theme' import { WalletEventName } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import { ShareableEntity } from 'uniswap/src/types/sharing' -import { openUri } from 'uniswap/src/utils/linking' import { logger } from 'utilities/src/logger/logger' -import { getNftCollectionUrl, getTwitterLink } from 'wallet/src/utils/linking' +import { getNftCollectionUrl, getTwitterLink, openUri } from 'wallet/src/utils/linking' type MenuOption = { title: string diff --git a/apps/mobile/src/features/notifications/WCNotification.tsx b/apps/mobile/src/features/notifications/WCNotification.tsx index 47087843a09..08323eb11ce 100644 --- a/apps/mobile/src/features/notifications/WCNotification.tsx +++ b/apps/mobile/src/features/notifications/WCNotification.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { useDispatch } from 'react-redux' +import { useAppDispatch } from 'src/app/hooks' import { openModal } from 'src/features/modals/modalSlice' import { iconSizes } from 'ui/src/theme' import { toSupportedChainId } from 'uniswap/src/features/chains/utils' @@ -14,7 +14,7 @@ import { formWCNotificationTitle } from 'wallet/src/features/notifications/utils export function WCNotification({ notification }: { notification: WalletConnectNotification }): JSX.Element { const { imageUrl, chainId, address, event, hideDelay, dappName } = notification - const dispatch = useDispatch() + const dispatch = useAppDispatch() const validChainId = toSupportedChainId(chainId) const title = formWCNotificationTitle(notification) diff --git a/apps/mobile/src/features/onboarding/OnboardingScreen.tsx b/apps/mobile/src/features/onboarding/OnboardingScreen.tsx index 0b9b8714bf2..21851e05e12 100644 --- a/apps/mobile/src/features/onboarding/OnboardingScreen.tsx +++ b/apps/mobile/src/features/onboarding/OnboardingScreen.tsx @@ -1,10 +1,7 @@ -import { useFocusEffect } from '@react-navigation/core' import { useHeaderHeight } from '@react-navigation/elements' -import React, { PropsWithChildren, useCallback } from 'react' -import { BackHandler, KeyboardAvoidingView, StyleSheet } from 'react-native' +import React, { PropsWithChildren } from 'react' +import { KeyboardAvoidingView, StyleSheet } from 'react-native' import { FadeIn, FadeOut } from 'react-native-reanimated' -import { renderHeaderBackButton } from 'src/app/navigation/components' -import { useOnboardingStackNavigation } from 'src/app/navigation/types' import { SHORT_SCREEN_HEADER_HEIGHT_RATIO, Screen } from 'src/components/layout/Screen' import { Flex, SpaceTokens, Text, useDeviceInsets, useMedia } from 'ui/src' import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' @@ -17,7 +14,6 @@ type OnboardingScreenProps = { paddingTop?: SpaceTokens childrenGap?: SpaceTokens keyboardAvoidingViewEnabled?: boolean - disableGoBack?: boolean } export function OnboardingScreen({ @@ -26,29 +22,13 @@ export function OnboardingScreen({ children, paddingTop = '$none', keyboardAvoidingViewEnabled = true, - disableGoBack = false, }: PropsWithChildren): JSX.Element { - const navigation = useOnboardingStackNavigation() const headerHeight = useHeaderHeight() const insets = useDeviceInsets() const media = useMedia() const gapSize = media.short ? '$none' : '$spacing16' - useFocusEffect( - useCallback(() => { - navigation.setOptions({ - headerLeft: disableGoBack ? (): null => null : renderHeaderBackButton, - gestureEnabled: !disableGoBack, - }) - const subscription = BackHandler.addEventListener('hardwareBackPress', () => { - return disableGoBack - }) - - return subscription.remove - }, [navigation, disableGoBack]), - ) - return ( ): JSX.Element { + const [footerHeight, setFooterHeight] = useState(0) const headerHeight = useHeaderHeight() const colors = useSporeColors() const media = useMedia() + const keyboard = useKeyboardLayout() + + const header = ( + + + {title} + + {subtitle ? ( + + {subtitle} + + ) : null} + + ) + + const page = ( + + {children} + + ) const normalGradientPadding = 1.5 const responsiveGradientPadding = media.short ? 1.25 : normalGradientPadding @@ -39,54 +62,69 @@ export function SafeKeyboardOnboardingScreen({ /> ) - const page = ( - <> - {title || subtitle ? ( - - {title && ( - - {title} - - )} - {subtitle && ( - - {subtitle} - - )} - - ) : null} - - {children} - - - ) + const compact = keyboard.isVisible && keyboard.containerHeight !== 0 + const containerStyle = compact ? styles.compact : styles.expand + + // This makes sure this component behaves just like `behavior="padding"` when + // there's enough space on the screen to show all components. + const minHeight = minHeightWhenKeyboardExpanded && compact ? keyboard.containerHeight - footerHeight : 0 return ( - - + - {page} - - + + + {header} + {page} + + + { + setFooterHeight(height) + }} + > + {screenFooter} + + + {topGradient} + ) } const styles = StyleSheet.create({ + base: { + flex: 1, + justifyContent: 'flex-end', + }, + compact: { + flexGrow: 0, + }, + container: { + paddingBottom: spacing.spacing12, + }, + expand: { + flexGrow: 1, + }, gradient: { left: 0, position: 'absolute', right: 0, top: 0, - zIndex: 1, }, }) diff --git a/apps/mobile/src/features/onboarding/hooks.ts b/apps/mobile/src/features/onboarding/hooks.ts index 40debb481b5..55199d2e06f 100644 --- a/apps/mobile/src/features/onboarding/hooks.ts +++ b/apps/mobile/src/features/onboarding/hooks.ts @@ -1,5 +1,5 @@ import { SharedEventName } from '@uniswap/analytics-events' -import { useDispatch } from 'react-redux' +import { useAppDispatch } from 'src/app/hooks' import { OnboardingStackBaseParams, useOnboardingStackNavigation } from 'src/app/navigation/types' import { MobileAppsFlyerEvents } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent, sendAppsFlyerEvent } from 'uniswap/src/features/telemetry/send' @@ -20,7 +20,7 @@ export function useCompleteOnboardingCallback({ entryPoint, importType, }: OnboardingStackBaseParams): () => Promise { - const dispatch = useDispatch() + const dispatch = useAppDispatch() const { getAllOnboardingAccounts, finishOnboarding } = useOnboardingContext() const navigation = useOnboardingStackNavigation() diff --git a/apps/mobile/src/features/scantastic/ScantasticModal.tsx b/apps/mobile/src/features/scantastic/ScantasticModal.tsx index 3d4d4a4d1d2..7d802444423 100644 --- a/apps/mobile/src/features/scantastic/ScantasticModal.tsx +++ b/apps/mobile/src/features/scantastic/ScantasticModal.tsx @@ -1,7 +1,6 @@ import React, { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useDispatch } from 'react-redux' -import { useAppSelector } from 'src/app/hooks' +import { useAppDispatch, useAppSelector } from 'src/app/hooks' import { useBiometricAppSettings, useBiometricPrompt } from 'src/features/biometrics/hooks' import { closeAllModals } from 'src/features/modals/modalSlice' import { selectModalState } from 'src/features/modals/selectModalState' @@ -36,7 +35,7 @@ interface OtpStateApiResponse { export function ScantasticModal(): JSX.Element | null { const { t } = useTranslation() const colors = useSporeColors() - const dispatch = useDispatch() + const dispatch = useAppDispatch() // Use the first mnemonic account because zero-balance mnemonic accounts will fail to retrieve the mnemonic from rnEthers const account = useSignerAccounts().sort( diff --git a/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/ReceiveSummaryItem.stories.tsx b/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/ReceiveSummaryItem.stories.tsx index c7ea41de3fa..b452b27097f 100644 --- a/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/ReceiveSummaryItem.stories.tsx +++ b/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/ReceiveSummaryItem.stories.tsx @@ -1,9 +1,9 @@ import type { Meta, StoryObj } from '@storybook/react' import React from 'react' import { TokenDocument } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' -import { AssetType } from 'uniswap/src/entities/assets' import { UniverseChainId } from 'uniswap/src/types/chains' import { Routing } from 'wallet/src/data/tradingApi/__generated__/index' +import { AssetType } from 'wallet/src/entities/assets' import { ReceiveSummaryItem } from 'wallet/src/features/transactions/SummaryCards/SummaryItems/ReceiveSummaryItem' import TransactionSummaryLayout from 'wallet/src/features/transactions/SummaryCards/SummaryItems/TransactionSummaryLayout' import { diff --git a/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/SendSummaryItem.stories.tsx b/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/SendSummaryItem.stories.tsx index e459764c256..c7986bc1bbd 100644 --- a/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/SendSummaryItem.stories.tsx +++ b/apps/mobile/src/features/transactions/SummaryCards/SummaryItems/SendSummaryItem.stories.tsx @@ -1,8 +1,8 @@ import type { Meta, StoryObj } from '@storybook/react' import React from 'react' import { TokenDocument } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' -import { AssetType } from 'uniswap/src/entities/assets' import { UniverseChainId } from 'uniswap/src/types/chains' +import { AssetType } from 'wallet/src/entities/assets' import { SendSummaryItem } from 'wallet/src/features/transactions/SummaryCards/SummaryItems/SendSummaryItem' import TransactionSummaryLayout from 'wallet/src/features/transactions/SummaryCards/SummaryItems/TransactionSummaryLayout' import { diff --git a/apps/mobile/src/features/transactions/swap/hooks/useOnCloseSendModal.tsx b/apps/mobile/src/features/transactions/swap/hooks/useOnCloseSendModal.tsx index 26792643e47..c1d1cf036d4 100644 --- a/apps/mobile/src/features/transactions/swap/hooks/useOnCloseSendModal.tsx +++ b/apps/mobile/src/features/transactions/swap/hooks/useOnCloseSendModal.tsx @@ -1,10 +1,10 @@ import { useCallback } from 'react' -import { useDispatch } from 'react-redux' +import { useAppDispatch } from 'src/app/hooks' import { closeModal } from 'src/features/modals/modalSlice' import { ModalName } from 'uniswap/src/features/telemetry/constants' export function useOnCloseSendModal(): () => void { - const appDispatch = useDispatch() + const appDispatch = useAppDispatch() const onClose = useCallback((): void => { appDispatch(closeModal({ name: ModalName.Send })) diff --git a/apps/mobile/src/features/transactions/transfer/TransferFlow.tsx b/apps/mobile/src/features/transactions/transfer/TransferFlow.tsx index c957d8c40bd..6f215340815 100644 --- a/apps/mobile/src/features/transactions/transfer/TransferFlow.tsx +++ b/apps/mobile/src/features/transactions/transfer/TransferFlow.tsx @@ -1,7 +1,7 @@ import { providers } from 'ethers' import { default as React, useCallback, useEffect, useMemo, useReducer, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Keyboard, LayoutAnimation, StyleSheet, TouchableWithoutFeedback } from 'react-native' +import { StyleSheet, TouchableWithoutFeedback } from 'react-native' import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated' import { useShouldShowNativeKeyboard } from 'src/app/hooks' import { RecipientSelect } from 'src/components/RecipientSelect/RecipientSelect' @@ -14,30 +14,15 @@ import { Flex, useDeviceInsets, useSporeColors } from 'ui/src' import EyeIcon from 'ui/src/assets/icons/eye.svg' import { useDeviceDimensions } from 'ui/src/hooks/useDeviceDimensions' import { iconSizes } from 'ui/src/theme' -import { TokenSelectorModal, TokenSelectorVariation } from 'uniswap/src/components/TokenSelector/TokenSelector' import { useBottomSheetContext } from 'uniswap/src/components/modals/BottomSheetContext' import { HandleBar } from 'uniswap/src/components/modals/HandleBar' import Trace from 'uniswap/src/features/telemetry/Trace' import { ModalName, SectionName } from 'uniswap/src/features/telemetry/constants' -import { CurrencyField, TransactionState } from 'uniswap/src/features/transactions/transactionState/types' -import { TokenSelectorFlow } from 'uniswap/src/features/transactions/transfer/types' import { currencyAddress } from 'uniswap/src/utils/currencyId' -import { - useAddToSearchHistory, - useCommonTokensOptions, - useFavoriteTokensOptions, - useFilterCallbacks, - usePopularTokensOptions, - usePortfolioTokenOptions, - useTokenSectionsForEmptySearch, - useTokenSectionsForSearchResults, -} from 'wallet/src/components/TokenSelector/hooks' +import { TokenSelectorModal, TokenSelectorVariation } from 'wallet/src/components/TokenSelector/TokenSelector' import { WarningModal } from 'wallet/src/components/modals/WarningModal/WarningModal' -import { useWalletNavigation } from 'wallet/src/contexts/WalletNavigationContext' import { useTransactionGasFee } from 'wallet/src/features/gas/hooks' import { GasFeeResult, GasSpeed } from 'wallet/src/features/gas/types' -import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' -import { useTokenWarningDismissed } from 'wallet/src/features/tokens/safetyHooks' import { WarningAction, WarningSeverity } from 'wallet/src/features/transactions/WarningModal/types' import { useParsedSendWarnings } from 'wallet/src/features/transactions/hooks/useParsedTransactionWarnings' import { useTokenSelectorActionHandlers } from 'wallet/src/features/transactions/hooks/useTokenSelectorActionHandlers' @@ -46,6 +31,7 @@ import { INITIAL_TRANSACTION_STATE, transactionStateReducer, } from 'wallet/src/features/transactions/transactionState/transactionState' +import { CurrencyField, TransactionState } from 'wallet/src/features/transactions/transactionState/types' import { TransferReview } from 'wallet/src/features/transactions/transfer/TransferReview' import { TransferTokenForm } from 'wallet/src/features/transactions/transfer/TransferTokenForm' import { useDerivedTransferInfo } from 'wallet/src/features/transactions/transfer/hooks/useDerivedTransferInfo' @@ -57,9 +43,8 @@ import { } from 'wallet/src/features/transactions/transfer/hooks/useTransferCallback' import { useTransferTransactionRequest } from 'wallet/src/features/transactions/transfer/hooks/useTransferTransactionRequest' import { useTransferWarnings } from 'wallet/src/features/transactions/transfer/hooks/useTransferWarnings' -import { DerivedTransferInfo } from 'wallet/src/features/transactions/transfer/types' +import { DerivedTransferInfo, TokenSelectorFlow } from 'wallet/src/features/transactions/transfer/types' import { TransactionStep, TransferFlowProps } from 'wallet/src/features/transactions/types' -import { useActiveAccountAddressWithThrow } from 'wallet/src/features/wallet/hooks' interface TransferFormProps { prefilledState?: TransactionState @@ -72,9 +57,6 @@ export function TransferFlow({ prefilledState, onClose }: TransferFormProps): JS const { t } = useTranslation() const { fullWidth } = useDeviceDimensions() const { isSheetReady } = useBottomSheetContext() - const { formatNumberOrString, convertFiatAmountFormatted } = useLocalizationContext() - const { navigateToBuyOrReceiveWithEmptyWallet } = useWalletNavigation() - const { registerSearch } = useAddToSearchHistory() const [state, dispatch] = useReducer(transactionStateReducer, prefilledState || INITIAL_TRANSACTION_STATE) const derivedTransferInfo = useDerivedTransferInfo(state) @@ -84,8 +66,6 @@ export function TransferFlow({ prefilledState, onClose }: TransferFormProps): JS const { isFiatInput, exactAmountToken, exactAmountFiat } = derivedTransferInfo const { showRecipientSelector } = state - const activeAccountAddress = useActiveAccountAddressWithThrow() - const onSelectRecipient = useOnSelectRecipient(dispatch) const onSetShowRecipientSelector = useSetShowRecipientSelector(dispatch) @@ -231,25 +211,10 @@ export function TransferFlow({ prefilledState, onClose }: TransferFormProps): JS {!!state.selectingCurrencyField && ( Keyboard.dismiss()} - onPressAnimation={() => LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)} onSelectCurrency={onSelectCurrency} /> )} diff --git a/apps/mobile/src/features/transactions/transfer/TransferStatus.tsx b/apps/mobile/src/features/transactions/transfer/TransferStatus.tsx index 6264957444b..04d07fd7f7e 100644 --- a/apps/mobile/src/features/transactions/transfer/TransferStatus.tsx +++ b/apps/mobile/src/features/transactions/transfer/TransferStatus.tsx @@ -4,11 +4,11 @@ import { goBack } from 'src/app/navigation/rootNavigation' import { TransactionPending } from 'src/features/transactions/TransactionPending/TransactionPending' import { AppTFunction } from 'ui/src/i18n/types' import { FiatCurrencyInfo } from 'uniswap/src/features/fiatOnRamp/types' -import { CurrencyField } from 'uniswap/src/features/transactions/transactionState/types' import { NumberType } from 'utilities/src/format/types' import { useAppFiatCurrencyInfo } from 'wallet/src/features/fiatCurrency/hooks' import { LocalizationContextState, useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' import { useSelectTransaction } from 'wallet/src/features/transactions/hooks' +import { CurrencyField } from 'wallet/src/features/transactions/transactionState/types' import { DerivedTransferInfo } from 'wallet/src/features/transactions/transfer/types' import { TransactionDetails, TransactionStatus, TransactionType } from 'wallet/src/features/transactions/types' import { useActiveAccountAddressWithThrow, useDisplayName } from 'wallet/src/features/wallet/hooks' diff --git a/apps/mobile/src/features/transactions/transfer/transferRewrite/TransferFlow.tsx b/apps/mobile/src/features/transactions/transfer/transferRewrite/TransferFlow.tsx index 2aeff67f62a..9610643d296 100644 --- a/apps/mobile/src/features/transactions/transfer/transferRewrite/TransferFlow.tsx +++ b/apps/mobile/src/features/transactions/transfer/transferRewrite/TransferFlow.tsx @@ -11,10 +11,10 @@ import { import { useWalletRestore } from 'src/features/wallet/hooks' import Trace from 'uniswap/src/features/telemetry/Trace' import { ModalName, SectionName } from 'uniswap/src/features/telemetry/constants' -import { TradeProtocolPreference } from 'uniswap/src/features/transactions/transactionState/types' import { SwapFormContextProvider, SwapFormState } from 'wallet/src/features/transactions/contexts/SwapFormContext' import { TransactionModal } from 'wallet/src/features/transactions/swap/TransactionModal' import { getFocusOnCurrencyFieldFromInitialState } from 'wallet/src/features/transactions/swap/hooks/useSwapPrefilledState' +import { TradeProtocolPreference } from 'wallet/src/features/transactions/transactionState/types' /** * @todo: The screens within this flow are not implemented. diff --git a/apps/mobile/src/features/unitags/ClaimUnitagScreen.tsx b/apps/mobile/src/features/unitags/ClaimUnitagScreen.tsx index c2fbf74b3c2..6ddd4ca74ca 100644 --- a/apps/mobile/src/features/unitags/ClaimUnitagScreen.tsx +++ b/apps/mobile/src/features/unitags/ClaimUnitagScreen.tsx @@ -16,7 +16,6 @@ import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' import { fonts, iconSizes, imageSizes, spacing } from 'ui/src/theme' import { TextInput } from 'uniswap/src/components/input/TextInput' import { Pill } from 'uniswap/src/components/pill/Pill' -import { LearnMoreLink } from 'uniswap/src/components/text/LearnMoreLink' import { uniswapUrls } from 'uniswap/src/constants/urls' import Trace from 'uniswap/src/features/telemetry/Trace' import { ElementName, ModalName, UnitagEventName } from 'uniswap/src/features/telemetry/constants' @@ -28,6 +27,7 @@ import { shortenAddress } from 'uniswap/src/utils/addresses' import { logger } from 'utilities/src/logger/logger' import { ONE_SECOND_MS } from 'utilities/src/time/time' import { WarningModal } from 'wallet/src/components/modals/WarningModal/WarningModal' +import { LearnMoreLink } from 'wallet/src/components/text/LearnMoreLink' import { useCreateOnboardingAccountIfNone, useOnboardingContext, diff --git a/apps/mobile/src/features/unitags/EditUnitagProfileScreen.tsx b/apps/mobile/src/features/unitags/EditUnitagProfileScreen.tsx index 7adbf15502a..a8454abf5e7 100644 --- a/apps/mobile/src/features/unitags/EditUnitagProfileScreen.tsx +++ b/apps/mobile/src/features/unitags/EditUnitagProfileScreen.tsx @@ -2,7 +2,6 @@ import React, { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { Keyboard, KeyboardAvoidingView, StyleSheet } from 'react-native' import ContextMenu from 'react-native-context-menu-view' -import { useDispatch } from 'react-redux' import { navigate } from 'src/app/navigation/rootNavigation' import { UnitagStackScreenProp } from 'src/app/navigation/types' import { BackHeader } from 'src/components/layout/BackHeader' @@ -39,6 +38,7 @@ import { useAvatarUploadCredsWithRefresh } from 'wallet/src/features/unitags/hoo import { useWalletSigners } from 'wallet/src/features/wallet/context' import { useAccount } from 'wallet/src/features/wallet/hooks' import { DisplayNameType } from 'wallet/src/features/wallet/types' +import { useAppDispatch } from 'wallet/src/state' const BIO_TEXT_INPUT_LINES = 6 @@ -71,7 +71,7 @@ export function EditUnitagProfileScreen({ route }: UnitagStackScreenProp void isModalOpen: boolean } { - const dispatch = useDispatch() + const dispatch = useAppDispatch() const openModalImmediately = params?.openModalImmediately // Means that no private key found for mnemonic wallets const [walletNeedsRestore, setWalletNeedsRestore] = useState(false) diff --git a/apps/mobile/src/features/walletConnect/signWcRequestSaga.ts b/apps/mobile/src/features/walletConnect/signWcRequestSaga.ts index c008cd87b51..89a0fecc631 100644 --- a/apps/mobile/src/features/walletConnect/signWcRequestSaga.ts +++ b/apps/mobile/src/features/walletConnect/signWcRequestSaga.ts @@ -2,10 +2,10 @@ import { providers } from 'ethers' import { wcWeb3Wallet } from 'src/features/walletConnect/saga' import { TransactionRequest, UwuLinkErc20Request } from 'src/features/walletConnect/walletConnectSlice' import { call, put } from 'typed-redux-saga' -import { AssetType } from 'uniswap/src/entities/assets' import { UniverseChainId, WalletChainId } from 'uniswap/src/types/chains' import { DappInfo, EthMethod, EthSignMethod, UwULinkMethod, WalletConnectEvent } from 'uniswap/src/types/walletConnect' import { logger } from 'utilities/src/logger/logger' +import { AssetType } from 'wallet/src/entities/assets' import { pushNotification } from 'wallet/src/features/notifications/slice' import { AppNotificationType } from 'wallet/src/features/notifications/types' import { SendTransactionParams, sendTransaction } from 'wallet/src/features/transactions/sendTransactionSaga' diff --git a/apps/mobile/src/features/walletConnect/utils.test.ts b/apps/mobile/src/features/walletConnect/utils.test.ts index d3fd396eeaa..6f027b88bae 100644 --- a/apps/mobile/src/features/walletConnect/utils.test.ts +++ b/apps/mobile/src/features/walletConnect/utils.test.ts @@ -14,11 +14,15 @@ const TEST_ADDRESS = '0xdFb84E543C39ACa3c6a39ea4e3B6c40eE7d2EBdA' describe(getAccountAddressFromEIP155String, () => { it('handles valid eip155 mainnet address', () => { - expect(getAccountAddressFromEIP155String(`${EIP155_MAINNET}:${TEST_ADDRESS}`)).toBe(TEST_ADDRESS) + expect(getAccountAddressFromEIP155String(`${EIP155_MAINNET}:${TEST_ADDRESS}`)).toBe( + TEST_ADDRESS + ) }) it('handles valid eip155 polygon address', () => { - expect(getAccountAddressFromEIP155String(`${EIP155_POLYGON}:${TEST_ADDRESS}`)).toBe(TEST_ADDRESS) + expect(getAccountAddressFromEIP155String(`${EIP155_POLYGON}:${TEST_ADDRESS}`)).toBe( + TEST_ADDRESS + ) }) it('handles invalid eip155 address', () => { @@ -28,18 +32,15 @@ describe(getAccountAddressFromEIP155String, () => { describe(getSupportedWalletConnectChains, () => { it('handles list of valid chains', () => { - expect(getSupportedWalletConnectChains([EIP155_MAINNET, EIP155_POLYGON, EIP155_OPTIMISM])).toEqual([ - UniverseChainId.Mainnet, - UniverseChainId.Polygon, - UniverseChainId.Optimism, - ]) + expect( + getSupportedWalletConnectChains([EIP155_MAINNET, EIP155_POLYGON, EIP155_OPTIMISM]) + ).toEqual([UniverseChainId.Mainnet, UniverseChainId.Polygon, UniverseChainId.Optimism]) }) it('handles list of valid chains including an invalid chain', () => { - expect(getSupportedWalletConnectChains([EIP155_MAINNET, EIP155_POLYGON, EIP155_LINEA_UNSUPPORTED])).toEqual([ - UniverseChainId.Mainnet, - UniverseChainId.Polygon, - ]) + expect( + getSupportedWalletConnectChains([EIP155_MAINNET, EIP155_POLYGON, EIP155_LINEA_UNSUPPORTED]) + ).toEqual([UniverseChainId.Mainnet, UniverseChainId.Polygon]) }) }) diff --git a/apps/mobile/src/features/widgets/widgets.ts b/apps/mobile/src/features/widgets/widgets.ts index c1768c855e6..367ee40607e 100644 --- a/apps/mobile/src/features/widgets/widgets.ts +++ b/apps/mobile/src/features/widgets/widgets.ts @@ -1,7 +1,6 @@ import { NativeModules } from 'react-native' import { getItem, reloadAllTimelines, setItem } from 'react-native-widgetkit' import { getBuildVariant } from 'src/utils/version' -import { currencyIdToContractInput } from 'uniswap/src/features/dataApi/utils' import { MobileEventName } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import { CurrencyId } from 'uniswap/src/types/currency' @@ -9,6 +8,7 @@ import { WidgetEvent } from 'uniswap/src/types/widgets' import { isAndroid } from 'utilities/src/platform' // eslint-disable-next-line no-restricted-imports import { analytics } from 'utilities/src/telemetry/analytics/analytics' +import { currencyIdToContractInput } from 'wallet/src/features/dataApi/utils' import { Account, AccountType } from 'wallet/src/features/wallet/accounts/types' const APP_GROUP = 'group.com.uniswap.widgets' diff --git a/apps/mobile/src/screens/AppLoadingScreen.tsx b/apps/mobile/src/screens/AppLoadingScreen.tsx index 1be91164dfb..47467f4104e 100644 --- a/apps/mobile/src/screens/AppLoadingScreen.tsx +++ b/apps/mobile/src/screens/AppLoadingScreen.tsx @@ -2,8 +2,8 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack' import dayjs from 'dayjs' import { isEnrolledAsync } from 'expo-local-authentication' import { t } from 'i18next' +import { isNumber } from 'lodash' import { useCallback, useEffect, useState } from 'react' -import { useDispatch } from 'react-redux' import { OnboardingStackParamList } from 'src/app/navigation/types' import { SplashScreen } from 'src/features/appLoading/SplashScreen' import { useBiometricContext } from 'src/features/biometrics/context' @@ -14,8 +14,8 @@ import { } from 'src/features/notifications/hooks/useNotificationOSPermissionsEnabled' import { RecoveryWalletInfo, useOnDeviceRecoveryData } from 'src/screens/Import/useOnDeviceRecoveryData' import { hideSplashScreen } from 'src/utils/splashScreen' -import { DynamicConfigs, OnDeviceRecoveryConfigKey } from 'uniswap/src/features/gating/configs' -import { useDynamicConfigValue } from 'uniswap/src/features/gating/hooks' +import { DynamicConfigs } from 'uniswap/src/features/gating/configs' +import { useDynamicConfig } from 'uniswap/src/features/gating/hooks' import { MobileEventName } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import { ImportType, OnboardingEntryPoint } from 'uniswap/src/types/onboarding' @@ -26,7 +26,7 @@ import { Keyring } from 'wallet/src/features/wallet/Keyring/Keyring' import { AccountType, SignerMnemonicAccount } from 'wallet/src/features/wallet/accounts/types' import { selectAnyAddressHasNotificationsEnabled } from 'wallet/src/features/wallet/selectors' import { setFinishedOnboarding } from 'wallet/src/features/wallet/slice' -import { useAppSelector } from 'wallet/src/state' +import { useAppDispatch, useAppSelector } from 'wallet/src/state' export const SPLASH_SCREEN = { uri: 'SplashScreen' } @@ -35,7 +35,7 @@ type Props = NativeStackScreenProps void } { - const dispatch = useDispatch() + const dispatch = useAppDispatch() const { setRecoveredImportedAccounts, finishOnboarding } = useOnboardingContext() const notificationOSPermission = useNotificationOSPermissionsEnabled() @@ -120,18 +120,15 @@ function useFinishAutomatedRecovery(navigation: Props['navigation']): { const FALLBACK_APP_LOADING_TIMEOUT_MS = 15000 export function AppLoadingScreen({ navigation }: Props): JSX.Element | null { - const dispatch = useDispatch() + const dispatch = useAppDispatch() - const appLoadingTimeoutMs = useDynamicConfigValue( - DynamicConfigs.OnDeviceRecovery, - OnDeviceRecoveryConfigKey.AppLoadingTimeoutMs, + const onDeviceRecoveryConfig = useDynamicConfig(DynamicConfigs.OnDeviceRecovery) + const appLoadingTimeoutMs = onDeviceRecoveryConfig.get( + 'appLoadingTimeoutMs', FALLBACK_APP_LOADING_TIMEOUT_MS, + isNumber, ) - const maxMnemonicsToLoad = useDynamicConfigValue( - DynamicConfigs.OnDeviceRecovery, - OnDeviceRecoveryConfigKey.MaxMnemonicsToLoad, - 20, - ) + const maxMnemonicsToLoad = onDeviceRecoveryConfig.getValue('maxMnemonicsToLoad', 20) as number const [finished, setFinished] = useState(false) diff --git a/apps/mobile/src/screens/DevScreen.tsx b/apps/mobile/src/screens/DevScreen.tsx index 190e1e8102c..b7260c6dce2 100644 --- a/apps/mobile/src/screens/DevScreen.tsx +++ b/apps/mobile/src/screens/DevScreen.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react' import { I18nManager, ScrollView } from 'react-native' -import { useDispatch } from 'react-redux' +import { useAppDispatch } from 'src/app/hooks' import { navigate } from 'src/app/navigation/rootNavigation' import { BackButton } from 'src/components/buttons/BackButton' import { Screen } from 'src/components/layout/Screen' @@ -22,7 +22,7 @@ import { useAppSelector } from 'wallet/src/state' export function DevScreen(): JSX.Element { const insets = useDeviceInsets() - const dispatch = useDispatch() + const dispatch = useAppDispatch() const activeAccount = useActiveAccount() const [rtlEnabled, setRTLEnabled] = useState(I18nManager.isRTL) const sortedMnemonicAccounts = useAppSelector(selectSortedSignerMnemonicAccounts) diff --git a/apps/mobile/src/screens/ExchangeTransferConnecting.tsx b/apps/mobile/src/screens/ExchangeTransferConnecting.tsx index ab3cb778675..dcb551c5474 100644 --- a/apps/mobile/src/screens/ExchangeTransferConnecting.tsx +++ b/apps/mobile/src/screens/ExchangeTransferConnecting.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useDispatch } from 'react-redux' +import { useAppDispatch } from 'src/app/hooks' import { Screen } from 'src/components/layout/Screen' import { useFiatOnRampTransactionCreator } from 'src/features/fiatOnRamp/hooks' import { Flex, useIsDarkMode } from 'ui/src' @@ -13,14 +13,13 @@ import { getServiceProviderLogo } from 'uniswap/src/features/fiatOnRamp/utils' import { InstitutionTransferEventName } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import { UniverseChainId } from 'uniswap/src/types/chains' -import { openUri } from 'uniswap/src/utils/linking' import { ONE_SECOND_MS } from 'utilities/src/time/time' import { useTimeout } from 'utilities/src/time/timing' import { ImageUri } from 'wallet/src/features/images/ImageUri' import { pushNotification } from 'wallet/src/features/notifications/slice' import { AppNotificationType } from 'wallet/src/features/notifications/types' -import { FiatPurchaseTransactionInfo } from 'wallet/src/features/transactions/types' import { useActiveAccountAddressWithThrow } from 'wallet/src/features/wallet/hooks' +import { openUri } from 'wallet/src/utils/linking' // Design decision const CONNECTING_TIMEOUT = 2 * ONE_SECOND_MS @@ -33,19 +32,15 @@ export function ExchangeTransferConnecting({ onClose: () => void }): JSX.Element { const { t } = useTranslation() - const dispatch = useDispatch() + const dispatch = useAppDispatch() const activeAccountAddress = useActiveAccountAddressWithThrow() const [timeoutElapsed, setTimeoutElapsed] = useState(false) - const initialTypeInfo = useMemo>( - () => ({ serviceProviderLogo: serviceProvider.logos, serviceProvider: serviceProvider.serviceProvider }), - [serviceProvider], - ) + const initialTypeInfo = useMemo(() => ({ serviceProviderLogo: serviceProvider.logos }), [serviceProvider.logos]) const { externalTransactionId, dispatchAddTransaction } = useFiatOnRampTransactionCreator( activeAccountAddress, UniverseChainId.Mainnet, - serviceProvider.serviceProvider, initialTypeInfo, ) diff --git a/apps/mobile/src/screens/ExploreScreen.tsx b/apps/mobile/src/screens/ExploreScreen.tsx index e1caeb84407..b6b0e91cc05 100644 --- a/apps/mobile/src/screens/ExploreScreen.tsx +++ b/apps/mobile/src/screens/ExploreScreen.tsx @@ -2,7 +2,7 @@ import { useScrollToTop } from '@react-navigation/native' import { SharedEventName } from '@uniswap/analytics-events' import React, { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Keyboard, KeyboardAvoidingView, TextInput } from 'react-native' +import { KeyboardAvoidingView, TextInput } from 'react-native' import { FadeIn, FadeOut } from 'react-native-reanimated' import { useAppSelector } from 'src/app/hooks' import { useExploreStackNavigation } from 'src/app/navigation/types' @@ -16,11 +16,11 @@ import { ColorTokens, Flex, flexStyles, useIsDarkMode } from 'ui/src' import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' import { useBottomSheetContext } from 'uniswap/src/components/modals/BottomSheetContext' import { HandleBar } from 'uniswap/src/components/modals/HandleBar' -import { SearchTextInput } from 'uniswap/src/features/search/SearchTextInput' import { ModalName, SectionName } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import { MobileScreens } from 'uniswap/src/types/screens/mobile' import { useDebounce } from 'utilities/src/time/timing' +import { SearchTextInput } from 'wallet/src/features/search/SearchTextInput' export function ExploreScreen(): JSX.Element { const modalInitialState = useAppSelector(selectModalState(ModalName.Explore)).initialState @@ -83,13 +83,12 @@ export function ExploreScreen(): JSX.Element { showShadow={!isSearchMode} onCancel={onSearchCancel} onChangeText={onSearchChangeText} - onDismiss={() => Keyboard.dismiss()} onFocus={onSearchFocus} /> {isSearchMode ? ( - + {debouncedSearchQuery.length === 0 ? ( diff --git a/apps/mobile/src/screens/FiatOnRampConnecting.tsx b/apps/mobile/src/screens/FiatOnRampConnecting.tsx index f15f73aed15..03e854dfcd7 100644 --- a/apps/mobile/src/screens/FiatOnRampConnecting.tsx +++ b/apps/mobile/src/screens/FiatOnRampConnecting.tsx @@ -2,7 +2,7 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack' import { skipToken } from '@reduxjs/toolkit/query/react' import React, { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useDispatch } from 'react-redux' +import { useAppDispatch } from 'src/app/hooks' import { FiatOnRampStackParamList } from 'src/app/navigation/types' import { Screen } from 'src/components/layout/Screen' import { useFiatOnRampContext } from 'src/features/fiatOnRamp/FiatOnRampContext' @@ -14,12 +14,11 @@ import { uniswapUrls } from 'uniswap/src/constants/urls' import { FiatOnRampConnectingView } from 'uniswap/src/features/fiatOnRamp/FiatOnRampConnectingView' import { useFiatOnRampAggregatorWidgetQuery } from 'uniswap/src/features/fiatOnRamp/api' import { ServiceProviderLogoStyles } from 'uniswap/src/features/fiatOnRamp/constants' -import { getOptionalServiceProviderLogo } from 'uniswap/src/features/fiatOnRamp/utils' +import { getOptionalServiceProviderLogo, getServiceProviderForQuote } from 'uniswap/src/features/fiatOnRamp/utils' import { FiatOnRampEventName, ModalName } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import { UniverseChainId } from 'uniswap/src/types/chains' import { FiatOnRampScreens } from 'uniswap/src/types/screens/mobile' -import { openUri } from 'uniswap/src/utils/linking' import { ONE_SECOND_MS } from 'utilities/src/time/time' import { useTimeout } from 'utilities/src/time/timing' import { ImageUri } from 'wallet/src/features/images/ImageUri' @@ -27,8 +26,8 @@ import { useLocalizationContext } from 'wallet/src/features/language/Localizatio import { pushNotification } from 'wallet/src/features/notifications/slice' import { AppNotificationType } from 'wallet/src/features/notifications/types' import { forceFetchFiatOnRampTransactions } from 'wallet/src/features/transactions/slice' -import { FiatPurchaseTransactionInfo } from 'wallet/src/features/transactions/types' import { useActiveAccountAddressWithThrow } from 'wallet/src/features/wallet/hooks' +import { openUri } from 'wallet/src/utils/linking' // Design decision const CONNECTING_TIMEOUT = 2 * ONE_SECOND_MS @@ -37,27 +36,28 @@ type Props = NativeStackScreenProps>( - () => ({ - serviceProviderLogo: serviceProvider?.logos, - serviceProvider: serviceProvider?.serviceProvider, - }), - [serviceProvider], - ) + const initialTypeInfo = useMemo(() => ({ serviceProviderLogo: serviceProvider?.logos }), [serviceProvider?.logos]) const { externalTransactionId, dispatchAddTransaction } = useFiatOnRampTransactionCreator( activeAccountAddress, quoteCurrency.currencyInfo?.currency.chainId ?? UniverseChainId.Mainnet, - serviceProvider?.serviceProvider, initialTypeInfo, ) @@ -105,7 +105,7 @@ export function FiatOnRampConnectingScreen({ navigation }: Props): JSX.Element | sendAnalyticsEvent(FiatOnRampEventName.FiatOnRampWidgetOpened, { externalTransactionId, serviceProvider: serviceProvider.serviceProvider, - preselectedServiceProvider: quotesSections?.[0]?.data?.[0]?.serviceProviderDetails.serviceProvider, + preselectedServiceProvider: quotesSections?.[0]?.data?.[0]?.serviceProvider, countryCode, countryState, fiatCurrency: baseCurrencyInfo?.code.toLowerCase(), diff --git a/apps/mobile/src/screens/FiatOnRampScreen.tsx b/apps/mobile/src/screens/FiatOnRampScreen.tsx index 2b32e658140..e5854f6b8b7 100644 --- a/apps/mobile/src/screens/FiatOnRampScreen.tsx +++ b/apps/mobile/src/screens/FiatOnRampScreen.tsx @@ -4,8 +4,7 @@ import { useTranslation } from 'react-i18next' import { TextInput, TextInputProps } from 'react-native' import FastImage from 'react-native-fast-image' import { FadeIn, FadeOut, FadeOutDown } from 'react-native-reanimated' -import { useDispatch } from 'react-redux' -import { useShouldShowNativeKeyboard } from 'src/app/hooks' +import { useAppDispatch, useShouldShowNativeKeyboard } from 'src/app/hooks' import { FiatOnRampStackParamList } from 'src/app/navigation/types' import { FiatOnRampCtaButton } from 'src/components/fiatOnRamp/CtaButton' import { Screen } from 'src/components/layout/Screen' @@ -15,19 +14,23 @@ import { FiatOnRampCountryListModal } from 'src/features/fiatOnRamp/FiatOnRampCo import { FiatOnRampTokenSelectorModal } from 'src/features/fiatOnRamp/FiatOnRampTokenSelector' import { useFiatOnRampQuotes, - useFiatOnRampSupportedTokens, useMeldFiatCurrencySupportInfo, useParseFiatOnRampError, -} from 'src/features/fiatOnRamp/hooks' +} from 'src/features/fiatOnRamp/aggregatorHooks' +import { useFiatOnRampSupportedTokens } from 'src/features/fiatOnRamp/hooks' import { Flex, Text, useIsDarkMode } from 'ui/src' import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' import { useBottomSheetContext } from 'uniswap/src/components/modals/BottomSheetContext' import { HandleBar } from 'uniswap/src/components/modals/HandleBar' import { FiatOnRampCountryPicker } from 'uniswap/src/features/fiatOnRamp/FiatOnRampCountryPicker' -import { useFiatOnRampAggregatorGetCountryQuery } from 'uniswap/src/features/fiatOnRamp/api' +import { + useFiatOnRampAggregatorGetCountryQuery, + useFiatOnRampAggregatorServiceProvidersQuery, +} from 'uniswap/src/features/fiatOnRamp/api' import { FORQuote, FORServiceProvider, + FORTransaction, FiatOnRampCurrency, InitialQuoteSelection, } from 'uniswap/src/features/fiatOnRamp/types' @@ -40,23 +43,26 @@ import { usePrevious } from 'utilities/src/react/hooks' import { DEFAULT_DELAY, useDebounce } from 'utilities/src/time/timing' import { DecimalPadLegacy } from 'wallet/src/components/legacy/DecimalPadLegacy' import { useLocalFiatToUSDConverter } from 'wallet/src/features/fiatCurrency/hooks' +import { useFiatOnRampAggregatorTransactionQuery } from 'wallet/src/features/fiatOnRamp/api' import { pushNotification } from 'wallet/src/features/notifications/slice' import { AppNotificationType } from 'wallet/src/features/notifications/types' type Props = NativeStackScreenProps -function selectInitialQuote(quotes: FORQuote[] | undefined): { - quote: FORQuote | undefined - type: InitialQuoteSelection | undefined -} { - const quoteFromLastUsedProvider = quotes?.find((q) => q.isMostRecentlyUsedProvider) - if (quoteFromLastUsedProvider) { - return { - quote: quoteFromLastUsedProvider, - type: InitialQuoteSelection.MostRecent, +function selectInitialQuote( + quotes: FORQuote[] | undefined, + lastTransaction: FORTransaction | undefined, +): { quote: FORQuote | undefined; type: InitialQuoteSelection | undefined } { + const lastUsedServiceProvider = lastTransaction?.serviceProvider + if (lastUsedServiceProvider) { + const quote = quotes?.filter((q) => q.serviceProvider === lastUsedServiceProvider)[0] + if (quote) { + return { + quote, + type: InitialQuoteSelection.MostRecent, + } } } - const bestQuote = quotes && quotes.length && quotes[0] if (bestQuote) { return { @@ -79,7 +85,7 @@ const PREDEFINED_AMOUNTS_SUPPORTED_CURRENCIES = ['usd', 'eur', 'gbp', 'aud', 'ca export function FiatOnRampScreen({ navigation }: Props): JSX.Element { const { t } = useTranslation() - const dispatch = useDispatch() + const dispatch = useAppDispatch() const isDarkMode = useIsDarkMode() const [selection, setSelection] = useState() const [value, setValue] = useState('') @@ -100,6 +106,7 @@ export function FiatOnRampScreen({ navigation }: Props): JSX.Element { amount, setAmount, setBaseCurrencyInfo, + setServiceProviders, quoteCurrency, setQuoteCurrency, } = useFiatOnRampContext() @@ -137,20 +144,29 @@ export function FiatOnRampScreen({ navigation }: Props): JSX.Element { } }, [ipCountryData, setCountryCode, setCountryState]) + const { + currentData: serviceProvidersResponse, + isFetching: serviceProvidersLoading, + error: serviceProvidersError, + } = useFiatOnRampAggregatorServiceProvidersQuery({ countryCode }) + // preload service provider logos for given quotes for the next screen useEffect(() => { - if (quotes) { - preloadServiceProviderLogos( - quotes.map((q) => q.serviceProviderDetails), - isDarkMode, + if (serviceProvidersResponse?.serviceProviders && quotes) { + const quotesServiceProviderNames = quotes.map((q) => q.serviceProvider) + const serviceProviders = serviceProvidersResponse.serviceProviders.filter( + (sp) => quotesServiceProviderNames.indexOf(sp.serviceProvider) !== -1, ) + preloadServiceProviderLogos(serviceProviders, isDarkMode) } - }, [quotes, isDarkMode]) + }, [serviceProvidersResponse, quotes, isDarkMode]) + + const { currentData: transactionResponse } = useFiatOnRampAggregatorTransactionQuery({}) const prevQuotes = usePrevious(quotes) useEffect(() => { if (quotes && (!selectedQuote || prevQuotes !== quotes)) { - const { quote, type } = selectInitialQuote(quotes) + const { quote, type } = selectInitialQuote(quotes, transactionResponse?.transaction) if (!quote) { return } @@ -162,14 +178,14 @@ export function FiatOnRampScreen({ navigation }: Props): JSX.Element { } setSelectedQuote(quote) } - }, [prevQuotes, quotes, selectedQuote, setQuotesSections, setSelectedQuote, t]) + }, [prevQuotes, quotes, selectedQuote, setQuotesSections, setSelectedQuote, t, transactionResponse]) useEffect(() => { - if (!quotes && (quotesError || !amount)) { + if (!quotes && (quotesError || serviceProvidersError || !amount)) { setQuotesSections(undefined) setSelectedQuote(undefined) } - }, [amount, quotesError, quotes, setQuotesSections, setSelectedQuote]) + }, [amount, quotesError, serviceProvidersError, quotes, setQuotesSections, setSelectedQuote]) const onSelectCountry: ComponentProps['onSelectCountry'] = (country): void => { dispatch( @@ -208,11 +224,22 @@ export function FiatOnRampScreen({ navigation }: Props): JSX.Element { }, [showNativeKeyboard, showTokenSelector]) // we only show loading when there are no errors and quote value is not empty - const buttonDisabled = selectTokenLoading || !!quotesError || !selectedQuote?.destinationAmount + const buttonDisabled = + serviceProvidersLoading || + !!serviceProvidersError || + selectTokenLoading || + !!quotesError || + !selectedQuote?.destinationAmount const onContinue = (): void => { - if (quotes && quoteCurrency?.currencyInfo?.currency) { + if ( + quotes && + serviceProvidersResponse?.serviceProviders && + serviceProvidersResponse?.serviceProviders.length > 0 && + quoteCurrency?.currencyInfo?.currency + ) { setBaseCurrencyInfo(meldSupportedFiatCurrency) + setServiceProviders(serviceProvidersResponse.serviceProviders) navigation.navigate(FiatOnRampScreens.ServiceProviders) } } @@ -245,7 +272,7 @@ export function FiatOnRampScreen({ navigation }: Props): JSX.Element { const notAvailableInThisRegion = supportedFiatCurrencies?.length === 0 const { errorText, errorColor } = useParseFiatOnRampError( - !notAvailableInThisRegion && quotesError, + !notAvailableInThisRegion && (quotesError || serviceProvidersError), meldSupportedFiatCurrency.code, ) diff --git a/apps/mobile/src/screens/FiatOnRampServiceProviders.tsx b/apps/mobile/src/screens/FiatOnRampServiceProviders.tsx index 2463528e366..c9cfe944a6c 100644 --- a/apps/mobile/src/screens/FiatOnRampServiceProviders.tsx +++ b/apps/mobile/src/screens/FiatOnRampServiceProviders.tsx @@ -12,14 +12,15 @@ import { ColorTokens, Flex, GeneratedIcon, Inset, Separator, Text } from 'ui/src import { TimePast } from 'ui/src/components/icons' import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' import { HandleBar } from 'uniswap/src/components/modals/HandleBar' -import { useBottomSheetFocusHook } from 'uniswap/src/components/modals/hooks' import { FORQuoteItem } from 'uniswap/src/features/fiatOnRamp/FORQuoteItem' import { FORQuote, InitialQuoteSelection } from 'uniswap/src/features/fiatOnRamp/types' +import { getServiceProviderForQuote } from 'uniswap/src/features/fiatOnRamp/utils' import { FiatOnRampScreens } from 'uniswap/src/types/screens/mobile' +import { useBottomSheetFocusHook } from 'wallet/src/components/modals/hooks' type Props = NativeStackScreenProps -const key = (item: FORQuote): string => item.serviceProviderDetails.serviceProvider +const key = (item: FORQuote): string => item.serviceProvider function SectionHeader({ Icon, @@ -54,11 +55,11 @@ function Footer(): JSX.Element { export function FiatOnRampServiceProvidersScreen({ navigation }: Props): JSX.Element { const { t } = useTranslation() - const { setSelectedQuote, quotesSections, baseCurrencyInfo } = useFiatOnRampContext() + const { setSelectedQuote, quotesSections, baseCurrencyInfo, serviceProviders } = useFiatOnRampContext() const renderItem = ({ item }: ListRenderItemInfo): JSX.Element => { const onPress = (): void => { - const serviceProvider = item.serviceProviderDetails + const serviceProvider = getServiceProviderForQuote(item, serviceProviders) if (serviceProvider) { setSelectedQuote(item) navigation.navigate(FiatOnRampScreens.Connecting) @@ -66,7 +67,9 @@ export function FiatOnRampServiceProvidersScreen({ navigation }: Props): JSX.Ele } return ( - {baseCurrencyInfo && } + {baseCurrencyInfo && ( + + )} ) } diff --git a/apps/mobile/src/screens/HomeScreen.tsx b/apps/mobile/src/screens/HomeScreen.tsx index bd488d774ad..8e11be9ef02 100644 --- a/apps/mobile/src/screens/HomeScreen.tsx +++ b/apps/mobile/src/screens/HomeScreen.tsx @@ -18,8 +18,7 @@ import Animated, { } from 'react-native-reanimated' import { SvgProps } from 'react-native-svg' import { SceneRendererProps, TabBar } from 'react-native-tab-view' -import { useDispatch } from 'react-redux' -import { useAppSelector } from 'src/app/hooks' +import { useAppDispatch, useAppSelector } from 'src/app/hooks' import { ExtensionPromoModal } from 'src/app/modals/ExtensionPromoModal' import { NavBar, SWAP_BUTTON_HEIGHT } from 'src/app/navigation/NavBar' import { AppStackScreenProp } from 'src/app/navigation/types' @@ -56,7 +55,6 @@ import SendIcon from 'ui/src/assets/icons/send-action.svg' import { AnimatedFlex } from 'ui/src/components/layout/AnimatedFlex' import { useDeviceDimensions } from 'ui/src/hooks/useDeviceDimensions' import { iconSizes, spacing } from 'ui/src/theme' -import { useCexTransferProviders } from 'uniswap/src/features/fiatOnRamp/useCexTransferProviders' import { FeatureFlags } from 'uniswap/src/features/gating/flags' import { useFeatureFlag } from 'uniswap/src/features/gating/hooks' import Trace from 'uniswap/src/features/telemetry/Trace' @@ -68,11 +66,11 @@ import { SectionName, SectionNameType, } from 'uniswap/src/features/telemetry/constants' -import { TestID } from 'uniswap/src/test/fixtures/testIDs' import { MobileScreens } from 'uniswap/src/types/screens/mobile' import { useTimeout } from 'utilities/src/time/timing' import { ScannerModalState } from 'wallet/src/components/QRCodeScanner/constants' import { selectHasSkippedUnitagPrompt } from 'wallet/src/features/behaviorHistory/selectors' +import { useCexTransferProviders } from 'wallet/src/features/fiatOnRamp/api' import { useSelectAddressHasNotifications } from 'wallet/src/features/notifications/hooks' import { setNotificationStatus } from 'wallet/src/features/notifications/slice' import { PortfolioBalance } from 'wallet/src/features/portfolio/PortfolioBalance' @@ -96,7 +94,7 @@ export function HomeScreen(props?: AppStackScreenProp): JSX. const media = useMedia() const insets = useDeviceInsets() const dimensions = useDeviceDimensions() - const dispatch = useDispatch() + const dispatch = useAppDispatch() const isFocused = useIsFocused() const isModalOpen = useAppSelector(selectSomeModalOpen) const isHomeScreenBlur = !isFocused || isModalOpen @@ -277,10 +275,19 @@ export function HomeScreen(props?: AppStackScreenProp): JSX. const { sync } = useScrollSync(currentTabIndex, scrollPairs, headerConfig) + const forAggregatorEnabled = useFeatureFlag(FeatureFlags.ForAggregator) const cexTransferEnabled = useFeatureFlag(FeatureFlags.CexTransfers) const cexTransferProviders = useCexTransferProviders(cexTransferEnabled) - const onPressBuy = useCallback(() => dispatch(openModal({ name: ModalName.FiatOnRampAggregator })), [dispatch]) + const onPressBuy = useCallback( + () => + dispatch( + openModal({ + name: forAggregatorEnabled ? ModalName.FiatOnRampAggregator : ModalName.FiatOnRamp, + }), + ), + [dispatch, forAggregatorEnabled], + ) const onPressScan = useCallback(() => { // in case we received a pending session from a previous scan after closing modal dispatch(removePendingSession()) @@ -404,8 +411,7 @@ export function HomeScreen(props?: AppStackScreenProp): JSX. const emptyComponentStyle = useMemo>( () => ({ minHeight: dimensions.fullHeight - (paddingTop + paddingBottom), - paddingTop: media.short ? spacing.spacing12 : spacing.spacing32, - paddingBottom: media.short ? spacing.spacing12 : spacing.spacing32, + paddingTop: spacing.none, paddingLeft: media.short ? spacing.none : spacing.spacing12, paddingRight: media.short ? spacing.none : spacing.spacing12, }), @@ -532,7 +538,6 @@ export function HomeScreen(props?: AppStackScreenProp): JSX. owner={activeAccount?.address} refreshing={refreshing} scrollHandler={tokensTabScrollHandler} - testID={TestID.TokensTab} onRefresh={onRefreshHomeData} /> @@ -549,7 +554,6 @@ export function HomeScreen(props?: AppStackScreenProp): JSX. owner={activeAccount?.address} refreshing={refreshing} scrollHandler={nftsTabScrollHandler} - testID={TestID.NFTsTab} onRefresh={onRefreshHomeData} /> @@ -564,7 +568,6 @@ export function HomeScreen(props?: AppStackScreenProp): JSX. owner={activeAccount?.address} refreshing={refreshing} scrollHandler={activityTabScrollHandler} - testID={TestID.ActivityTab} onRefresh={onRefreshHomeData} /> diff --git a/apps/mobile/src/screens/Import/OnDeviceRecoveryScreen.tsx b/apps/mobile/src/screens/Import/OnDeviceRecoveryScreen.tsx index bb0830413b2..be7897e6cdd 100644 --- a/apps/mobile/src/screens/Import/OnDeviceRecoveryScreen.tsx +++ b/apps/mobile/src/screens/Import/OnDeviceRecoveryScreen.tsx @@ -1,5 +1,6 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack' import dayjs from 'dayjs' +import { isNumber } from 'lodash' import React, { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { ScrollView } from 'react-native-gesture-handler' @@ -15,8 +16,8 @@ import { Flex, Image, Text, TouchableArea, useSporeColors } from 'ui/src' import { UNISWAP_LOGO } from 'ui/src/assets' import { PapersText } from 'ui/src/components/icons' import { iconSizes } from 'ui/src/theme' -import { DynamicConfigs, OnDeviceRecoveryConfigKey } from 'uniswap/src/features/gating/configs' -import { useDynamicConfigValue } from 'uniswap/src/features/gating/hooks' +import { DynamicConfigs } from 'uniswap/src/features/gating/configs' +import { useDynamicConfig } from 'uniswap/src/features/gating/hooks' import Trace from 'uniswap/src/features/telemetry/Trace' import { ModalName } from 'uniswap/src/features/telemetry/constants' import { TestID } from 'uniswap/src/test/fixtures/testIDs' @@ -44,10 +45,10 @@ export function OnDeviceRecoveryScreen({ const { t } = useTranslation() const colors = useSporeColors() const { setRecoveredImportedAccounts } = useOnboardingContext() - const recoveryLoadingTimeoutMs = useDynamicConfigValue( - DynamicConfigs.OnDeviceRecovery, - OnDeviceRecoveryConfigKey.AppLoadingTimeoutMs, + const recoveryLoadingTimeoutMs = useDynamicConfig(DynamicConfigs.OnDeviceRecovery).get( + 'recoveryLoadingTimeoutMs', FALLBACK_RECOVERY_LOADING_TIMEOUT_MS, + isNumber, ) const [selectedMnemonicId, setSelectedMnemonicId] = useState() diff --git a/apps/mobile/src/screens/Import/RestoreCloudBackupLoadingScreen.tsx b/apps/mobile/src/screens/Import/RestoreCloudBackupLoadingScreen.tsx index b83b50030e5..5803b6854f7 100644 --- a/apps/mobile/src/screens/Import/RestoreCloudBackupLoadingScreen.tsx +++ b/apps/mobile/src/screens/Import/RestoreCloudBackupLoadingScreen.tsx @@ -2,7 +2,7 @@ import { useFocusEffect } from '@react-navigation/core' import { NativeStackScreenProps } from '@react-navigation/native-stack' import React, { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useDispatch } from 'react-redux' +import { useAppDispatch } from 'src/app/hooks' import { OnboardingStackParamList } from 'src/app/navigation/types' import { startFetchingCloudStorageBackups, @@ -31,7 +31,7 @@ const MAX_LOADING_TIMEOUT_MS = ONE_SECOND_MS * 10 export function RestoreCloudBackupLoadingScreen({ navigation, route: { params } }: Props): JSX.Element { const { t } = useTranslation() - const dispatch = useDispatch() + const dispatch = useAppDispatch() const entryPoint = params.entryPoint const importType = params.importType diff --git a/apps/mobile/src/screens/Import/RestoreCloudBackupPasswordScreen.tsx b/apps/mobile/src/screens/Import/RestoreCloudBackupPasswordScreen.tsx index c9bc6e4f421..a139dd01bee 100644 --- a/apps/mobile/src/screens/Import/RestoreCloudBackupPasswordScreen.tsx +++ b/apps/mobile/src/screens/Import/RestoreCloudBackupPasswordScreen.tsx @@ -3,8 +3,7 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack' import React, { useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { Keyboard, TextInput } from 'react-native' -import { useDispatch } from 'react-redux' -import { useAppSelector } from 'src/app/hooks' +import { useAppDispatch, useAppSelector } from 'src/app/hooks' import { OnboardingStackParamList } from 'src/app/navigation/types' import { PasswordInput } from 'src/components/input/PasswordInput' import { restoreMnemonicFromCloudStorage } from 'src/features/CloudBackup/RNCloudStorageBackupsManager' @@ -70,7 +69,7 @@ function useLockoutTimeMessage(remainingLockoutTime: number): string { export function RestoreCloudBackupPasswordScreen({ navigation, route: { params } }: Props): JSX.Element { const { t } = useTranslation() const inputRef = useRef(null) - const dispatch = useDispatch() + const dispatch = useAppDispatch() const { generateImportedAccounts } = useOnboardingContext() const passwordAttemptCount = useAppSelector(selectPasswordAttempts) diff --git a/apps/mobile/src/screens/Import/SeedPhraseInput.tsx b/apps/mobile/src/screens/Import/SeedPhraseInput.tsx index 7324bf53581..7af3a0bbddb 100644 --- a/apps/mobile/src/screens/Import/SeedPhraseInput.tsx +++ b/apps/mobile/src/screens/Import/SeedPhraseInput.tsx @@ -26,7 +26,6 @@ export enum StringKey { } interface NativeSeedPhraseInputProps { targetMnemonicId?: string - testID?: string strings: Record onInputValidated: (e: NativeSyntheticEvent) => void onMnemonicStored: (e: NativeSyntheticEvent) => void diff --git a/apps/mobile/src/screens/Import/SeedPhraseInputScreen.tsx b/apps/mobile/src/screens/Import/SeedPhraseInputScreen.tsx index 08860bc6ead..40971533d4b 100644 --- a/apps/mobile/src/screens/Import/SeedPhraseInputScreen.tsx +++ b/apps/mobile/src/screens/Import/SeedPhraseInputScreen.tsx @@ -13,11 +13,11 @@ import Trace from 'uniswap/src/features/telemetry/Trace' import { ElementName } from 'uniswap/src/features/telemetry/constants' import { ImportType } from 'uniswap/src/types/onboarding' import { OnboardingScreens } from 'uniswap/src/types/screens/mobile' -import { openUri } from 'uniswap/src/utils/linking' import { useOnboardingContext } from 'wallet/src/features/onboarding/OnboardingContext' import { Keyring } from 'wallet/src/features/wallet/Keyring/Keyring' import { BackupType } from 'wallet/src/features/wallet/accounts/types' import { useSignerAccounts } from 'wallet/src/features/wallet/hooks' +import { openUri } from 'wallet/src/utils/linking' import { MnemonicValidationError, translateMnemonicErrorMessage, diff --git a/apps/mobile/src/screens/Import/SeedPhraseInputScreenV2.android.mock.tsx b/apps/mobile/src/screens/Import/SeedPhraseInputScreenV2.android.mock.tsx deleted file mode 100644 index 6dea2e459ed..00000000000 --- a/apps/mobile/src/screens/Import/SeedPhraseInputScreenV2.android.mock.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import { NativeStackScreenProps } from '@react-navigation/native-stack' -import React, { useCallback, useState } from 'react' -import { useTranslation } from 'react-i18next' -import { OnboardingStackParamList } from 'src/app/navigation/types' -import { useLockScreenOnBlur } from 'src/features/authentication/lockScreenContext' -import { GenericImportForm } from 'src/features/import/GenericImportForm' -import { SafeKeyboardOnboardingScreen } from 'src/features/onboarding/SafeKeyboardOnboardingScreen' -import { useAddBackButton } from 'src/utils/useAddBackButton' -import { Button, Flex, Text, TouchableArea } from 'ui/src' -import { QuestionInCircleFilled } from 'ui/src/components/icons' -import { uniswapUrls } from 'uniswap/src/constants/urls' -import Trace from 'uniswap/src/features/telemetry/Trace' -import { ElementName } from 'uniswap/src/features/telemetry/constants' -import { ImportType } from 'uniswap/src/types/onboarding' -import { OnboardingScreens } from 'uniswap/src/types/screens/mobile' -import { openUri } from 'uniswap/src/utils/linking' -import { useOnboardingContext } from 'wallet/src/features/onboarding/OnboardingContext' -import { Keyring } from 'wallet/src/features/wallet/Keyring/Keyring' -import { BackupType } from 'wallet/src/features/wallet/accounts/types' -import { useSignerAccounts } from 'wallet/src/features/wallet/hooks' -import { - MnemonicValidationError, - translateMnemonicErrorMessage, - userFinishedTypingWord, - validateMnemonic, - validateSetOfWords, -} from 'wallet/src/utils/mnemonics' - -type Props = NativeStackScreenProps - -// Original SeedPhraseInputScreen component including JS input field. Used as a mock for Android Detox e2e testing. -export function SeedPhraseInputScreenV2({ navigation, route: { params } }: Props): JSX.Element { - const { t } = useTranslation() - const { generateImportedAccountsByMnemonic } = useOnboardingContext() - - /** - * If paste permission modal is open, we need to manually disable the splash screen that appears on blur, - * since the modal triggers the same `inactive` app state as does going to app switcher - * - * Technically seed phrase will be blocked if user pastes from keyboard, - * but that is an extreme edge case. - **/ - const [pastePermissionModalOpen, setPastePermissionModalOpen] = useState(false) - useLockScreenOnBlur(pastePermissionModalOpen) - - const [value, setValue] = useState(undefined) - const [errorMessage, setErrorMessage] = useState(undefined) - - const isRestoringMnemonic = params.importType === ImportType.RestoreMnemonic - - useAddBackButton(navigation) - - const signerAccounts = useSignerAccounts() - const mnemonicId = (isRestoringMnemonic && signerAccounts[0]?.mnemonicId) || undefined - - // Add all accounts from mnemonic. - const onSubmit = useCallback(async () => { - // Check phrase validation - const { validMnemonic, error, invalidWord } = validateMnemonic(value) - - if (error) { - setErrorMessage(translateMnemonicErrorMessage(error, invalidWord, t)) - return - } - - if (!validMnemonic) { - return - } - - if (mnemonicId && validMnemonic) { - const generatedMnemonicId = await Keyring.generateAddressForMnemonic(validMnemonic, 0) - if (generatedMnemonicId !== mnemonicId) { - setErrorMessage(t('account.recoveryPhrase.error.wrong')) - return - } - } - - await generateImportedAccountsByMnemonic(validMnemonic, undefined, BackupType.Manual) - - // restore flow is handled in saga after `restoreMnemonicComplete` is dispatched - if (!isRestoringMnemonic) { - navigation.navigate({ name: OnboardingScreens.SelectWallet, params, merge: true }) - } - }, [value, mnemonicId, generateImportedAccountsByMnemonic, isRestoringMnemonic, t, navigation, params]) - - const onBlur = useCallback(() => { - const { error, invalidWord } = validateMnemonic(value) - if (error) { - setErrorMessage(translateMnemonicErrorMessage(error, invalidWord, t)) - } - }, [t, value]) - - const onChange = (text: string | undefined): void => { - const { error, invalidWord } = validateSetOfWords(text) - - // suppress error messages if the user is not done typing a word - const suppressError = - (error === MnemonicValidationError.InvalidWord && !userFinishedTypingWord(text)) || - error === MnemonicValidationError.NotEnoughWords - - if (!error || suppressError) { - setErrorMessage(undefined) - } else { - setErrorMessage(translateMnemonicErrorMessage(error, invalidWord, t)) - } - - setValue(text) - } - - const onPressRecoveryHelpButton = (): Promise => openUri(uniswapUrls.helpArticleUrls.recoveryPhraseHowToImport) - - const onPressTryAgainButton = (): void => { - navigation.replace(OnboardingScreens.RestoreCloudBackupLoading, params) - } - - return ( - - - - setPastePermissionModalOpen(false)} - beforePasteButtonPress={(): void => setPastePermissionModalOpen(true)} - errorMessage={errorMessage} - inputAlignment="flex-start" - placeholderLabel={t('account.recoveryPhrase.input')} - textAlign="left" - value={value} - onBlur={onBlur} - onChange={onChange} - /> - - - - - - - {isRestoringMnemonic - ? t('account.recoveryPhrase.helpText.restoring') - : t('account.recoveryPhrase.helpText.import')} - - - - - - - - - - ) -} diff --git a/apps/mobile/src/screens/Import/SeedPhraseInputScreenV2.tsx b/apps/mobile/src/screens/Import/SeedPhraseInputScreenV2.tsx index 65602aeba8b..c06c8eeca58 100644 --- a/apps/mobile/src/screens/Import/SeedPhraseInputScreenV2.tsx +++ b/apps/mobile/src/screens/Import/SeedPhraseInputScreenV2.tsx @@ -19,13 +19,12 @@ import { QuestionInCircleFilled } from 'ui/src/components/icons' import { uniswapUrls } from 'uniswap/src/constants/urls' import Trace from 'uniswap/src/features/telemetry/Trace' 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' -import { openUri } from 'uniswap/src/utils/linking' import { useOnboardingContext } from 'wallet/src/features/onboarding/OnboardingContext' import { BackupType } from 'wallet/src/features/wallet/accounts/types' import { useSignerAccounts } from 'wallet/src/features/wallet/hooks' +import { openUri } from 'wallet/src/utils/linking' type Props = NativeStackScreenProps @@ -72,13 +71,14 @@ export function SeedPhraseInputScreenV2({ navigation, route: { params } }: Props return ( } - minHeightWhenKeyboardExpanded={false} subtitle={ isRestoringMnemonic ? t('account.recoveryPhrase.subtitle.restoring') @@ -110,7 +109,6 @@ export function SeedPhraseInputScreenV2({ navigation, route: { params } }: Props [StringKey.ErrorInvalidPhrase]: t('account.recoveryPhrase.error.invalid'), }} targetMnemonicId={targetMnemonicId} - testID={TestID.ImportAccountInput} onInputValidated={(e: NativeSyntheticEvent): void => setSubmitEnabled(e.nativeEvent.canSubmit) } diff --git a/apps/mobile/src/screens/Import/WatchWalletScreen.tsx b/apps/mobile/src/screens/Import/WatchWalletScreen.tsx index 8c1ced23c6f..1b82ca22579 100644 --- a/apps/mobile/src/screens/Import/WatchWalletScreen.tsx +++ b/apps/mobile/src/screens/Import/WatchWalletScreen.tsx @@ -4,7 +4,7 @@ import { TFunction } from 'i18next' import React, { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { Keyboard } from 'react-native' -import { useDispatch } from 'react-redux' +import { useAppDispatch } from 'src/app/hooks' import { OnboardingStackParamList } from 'src/app/navigation/types' import { GenericImportForm } from 'src/features/import/GenericImportForm' import { SafeKeyboardOnboardingScreen } from 'src/features/onboarding/SafeKeyboardOnboardingScreen' @@ -71,7 +71,7 @@ const getErrorText = ({ export function WatchWalletScreen({ navigation, route: { params } }: Props): JSX.Element { const { t } = useTranslation() - const dispatch = useDispatch() + const dispatch = useAppDispatch() const accounts = useAccounts() const initialAccounts = useRef(accounts) diff --git a/apps/mobile/src/screens/NFTItemScreen.tsx b/apps/mobile/src/screens/NFTItemScreen.tsx index f8f94e583f8..ea8aaeaa9c8 100644 --- a/apps/mobile/src/screens/NFTItemScreen.tsx +++ b/apps/mobile/src/screens/NFTItemScreen.tsx @@ -5,8 +5,7 @@ import React, { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { StatusBar, StyleSheet, TouchableOpacity } from 'react-native' import ContextMenu from 'react-native-context-menu-view' -import { useDispatch } from 'react-redux' -import { useAppSelector } from 'src/app/hooks' +import { useAppDispatch, useAppSelector } from 'src/app/hooks' import { AppStackScreenProp, useAppStackNavigation } from 'src/app/navigation/types' import { HeaderScrollScreen } from 'src/components/layout/screens/HeaderScrollScreen' import { Loader } from 'src/components/loading' @@ -36,8 +35,6 @@ import { ModalName } from 'uniswap/src/features/telemetry/constants' import { UniverseChainId } from 'uniswap/src/types/chains' import { MobileScreens } from 'uniswap/src/types/screens/mobile' import { areAddressesEqual } from 'uniswap/src/utils/addresses' -import { setClipboardImage } from 'uniswap/src/utils/clipboard' -import { MIN_COLOR_CONTRAST_THRESHOLD, useNearestThemeColorFromImageUri } from 'uniswap/src/utils/colors' import { isAndroid, isIOS } from 'utilities/src/platform' import { AddressDisplay } from 'wallet/src/components/accounts/AddressDisplay' import { NFTViewer } from 'wallet/src/features/images/NFTViewer' @@ -46,6 +43,8 @@ import { useNFTContextMenu } from 'wallet/src/features/nfts/useNftContextMenu' import { pushNotification } from 'wallet/src/features/notifications/slice' import { AppNotificationType, CopyNotificationType } from 'wallet/src/features/notifications/types' import { useActiveAccountAddressWithThrow } from 'wallet/src/features/wallet/hooks' +import { setClipboardImage } from 'wallet/src/utils/clipboard' +import { MIN_COLOR_CONTRAST_THRESHOLD, useNearestThemeColorFromImageUri } from 'wallet/src/utils/colors' const MAX_NFT_IMAGE_HEIGHT = 375 @@ -72,7 +71,7 @@ function NFTItemScreenContents({ }: NFTItemScreenProps): JSX.Element { const { t } = useTranslation() const activeAccountAddress = useActiveAccountAddressWithThrow() - const dispatch = useDispatch() + const dispatch = useAppDispatch() const colors = useSporeColors() const navigation = useAppStackNavigation() diff --git a/apps/mobile/src/screens/Onboarding/CloudBackupPasswordConfirmScreen.tsx b/apps/mobile/src/screens/Onboarding/CloudBackupPasswordConfirmScreen.tsx index 214a4bddac0..55b0c05cfef 100644 --- a/apps/mobile/src/screens/Onboarding/CloudBackupPasswordConfirmScreen.tsx +++ b/apps/mobile/src/screens/Onboarding/CloudBackupPasswordConfirmScreen.tsx @@ -1,10 +1,9 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack' -import React, { useCallback } from 'react' +import React from 'react' import { useTranslation } from 'react-i18next' import { OnboardingStackParamList } from 'src/app/navigation/types' -import { CloudBackupPassword } from 'src/features/CloudBackup/CloudBackupForm' +import { CloudBackupPasswordForm } from 'src/features/CloudBackup/CloudBackupPasswordForm' import { SafeKeyboardOnboardingScreen } from 'src/features/onboarding/SafeKeyboardOnboardingScreen' -import { Flex } from 'ui/src' import { OnboardingScreens } from 'uniswap/src/types/screens/mobile' export type Props = NativeStackScreenProps @@ -13,31 +12,24 @@ export function CloudBackupPasswordConfirmScreen({ navigation, route: { params } const { t } = useTranslation() const { password } = params - const navigateToNextScreen = useCallback((): void => { + const navigateToNextScreen = (): void => { navigation.navigate({ name: OnboardingScreens.BackupCloudProcessing, params, merge: true, }) - }, [navigation, params]) + } return ( - - - - - } - subtitle={t('onboarding.cloud.confirm.description')} - title={t('onboarding.cloud.confirm.title')} - > - - - + + ) } diff --git a/apps/mobile/src/screens/Onboarding/CloudBackupPasswordCreateScreen.tsx b/apps/mobile/src/screens/Onboarding/CloudBackupPasswordCreateScreen.tsx index e71e8bb1d7f..5a22cf59288 100644 --- a/apps/mobile/src/screens/Onboarding/CloudBackupPasswordCreateScreen.tsx +++ b/apps/mobile/src/screens/Onboarding/CloudBackupPasswordCreateScreen.tsx @@ -1,10 +1,9 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack' -import React, { useCallback } from 'react' +import React from 'react' import { useTranslation } from 'react-i18next' import { OnboardingStackParamList } from 'src/app/navigation/types' -import { CloudBackupPassword } from 'src/features/CloudBackup/CloudBackupForm' +import { CloudBackupPasswordForm } from 'src/features/CloudBackup/CloudBackupPasswordForm' import { SafeKeyboardOnboardingScreen } from 'src/features/onboarding/SafeKeyboardOnboardingScreen' -import { Flex } from 'ui/src' import { OnboardingScreens } from 'uniswap/src/types/screens/mobile' export type Props = NativeStackScreenProps @@ -12,33 +11,23 @@ export type Props = NativeStackScreenProps { - navigation.navigate({ - name: OnboardingScreens.BackupCloudPasswordConfirm, - params: { - ...params, - password, - }, - merge: true, - }) - }, - [navigation, params], - ) + const navigateToNextScreen = ({ password }: { password: string }): void => { + navigation.navigate({ + name: OnboardingScreens.BackupCloudPasswordConfirm, + params: { + ...params, + password, + }, + merge: true, + }) + } return ( - - - - - } - subtitle={t('onboarding.cloud.createPassword.description')} - title={t('onboarding.cloud.createPassword.title')} - > - - - + + + ) } diff --git a/apps/mobile/src/screens/Onboarding/LandingScreen.tsx b/apps/mobile/src/screens/Onboarding/LandingScreen.tsx index 1b1536467cf..e615866b3b9 100644 --- a/apps/mobile/src/screens/Onboarding/LandingScreen.tsx +++ b/apps/mobile/src/screens/Onboarding/LandingScreen.tsx @@ -2,7 +2,7 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack' import React, { useCallback, useEffect } from 'react' import { useTranslation } from 'react-i18next' import { useAnimatedStyle, useSharedValue, withDelay, withTiming } from 'react-native-reanimated' -import { useDispatch } from 'react-redux' +import { useAppDispatch } from 'src/app/hooks' import { OnboardingStackParamList } from 'src/app/navigation/types' import { Screen } from 'src/components/layout/Screen' import { openModal } from 'src/features/modals/modalSlice' @@ -16,7 +16,6 @@ import { TestID } from 'uniswap/src/test/fixtures/testIDs' import { ImportType, OnboardingEntryPoint } from 'uniswap/src/types/onboarding' import { OnboardingScreens, UnitagScreens } from 'uniswap/src/types/screens/mobile' import { isDevEnv } from 'utilities/src/environment' -import { isDetoxBuild } from 'utilities/src/environment/constants' import { ONE_SECOND_MS } from 'utilities/src/time/time' import { useTimeout } from 'utilities/src/time/timing' import { LANDING_ANIMATION_DURATION, LandingBackground } from 'wallet/src/components/landing/LandingBackground' @@ -25,17 +24,14 @@ import { useCanAddressClaimUnitag } from 'wallet/src/features/unitags/hooks' type Props = NativeStackScreenProps export function LandingScreen({ navigation }: Props): JSX.Element { - const dispatch = useDispatch() + const dispatch = useAppDispatch() const { t } = useTranslation() const actionButtonsOpacity = useSharedValue(0) const actionButtonsStyle = useAnimatedStyle(() => ({ opacity: actionButtonsOpacity.value }), [actionButtonsOpacity]) useEffect(() => { - // disables looping animation during detox e2e tests which was preventing js thread from idle - if (!isDetoxBuild) { - actionButtonsOpacity.value = withDelay(LANDING_ANIMATION_DURATION, withTiming(1, { duration: ONE_SECOND_MS })) - } + actionButtonsOpacity.value = withDelay(LANDING_ANIMATION_DURATION, withTiming(1, { duration: ONE_SECOND_MS })) }, [actionButtonsOpacity]) const { canClaimUnitag } = useCanAddressClaimUnitag() diff --git a/apps/mobile/src/screens/Onboarding/NotificationsSetupScreen.tsx b/apps/mobile/src/screens/Onboarding/NotificationsSetupScreen.tsx index 69eafcdb07a..915a8b1edf1 100644 --- a/apps/mobile/src/screens/Onboarding/NotificationsSetupScreen.tsx +++ b/apps/mobile/src/screens/Onboarding/NotificationsSetupScreen.tsx @@ -1,8 +1,9 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack' -import React, { useCallback } from 'react' +import React, { useCallback, useEffect } from 'react' import { useTranslation } from 'react-i18next' import { Alert, Image, Platform, StyleSheet } from 'react-native' import { OnboardingStackParamList } from 'src/app/navigation/types' +import { BackButton } from 'src/components/buttons/BackButton' import { useBiometricContext } from 'src/features/biometrics/context' import { useBiometricAppSettings } from 'src/features/biometrics/hooks' import { promptPushPermission } from 'src/features/notifications/Onesignal' @@ -14,7 +15,7 @@ import Trace from 'uniswap/src/features/telemetry/Trace' import { ElementName } from 'uniswap/src/features/telemetry/constants' import i18n from 'uniswap/src/i18n/i18n' import { TestID } from 'uniswap/src/test/fixtures/testIDs' -import { OnboardingEntryPoint } from 'uniswap/src/types/onboarding' +import { ImportType, OnboardingEntryPoint } from 'uniswap/src/types/onboarding' import { OnboardingScreens } from 'uniswap/src/types/screens/mobile' import { isIOS } from 'utilities/src/platform' import { useOnboardingContext } from 'wallet/src/features/onboarding/OnboardingContext' @@ -45,6 +46,29 @@ export function NotificationsSetupScreen({ navigation, route: { params } }: Prop const onCompleteOnboarding = useCompleteOnboardingCallback(params) + const renderBackButton = useCallback( + (nav: OnboardingScreens): JSX.Element => ( + navigation.navigate({ name: nav, params, merge: true })} /> + ), + [navigation, params], + ) + + /* For some screens, we want to override the back button to go to a different screen. + * This helps avoid re-visiting loading states or confirmation views. + */ + useEffect(() => { + const shouldOverrideBackButton = [ImportType.SeedPhrase, ImportType.Restore, ImportType.CreateNew].includes( + params.importType, + ) + if (shouldOverrideBackButton) { + const nextScreen = + params.importType === ImportType.Restore ? OnboardingScreens.RestoreCloudBackup : OnboardingScreens.Backup + navigation.setOptions({ + headerLeft: () => renderBackButton(nextScreen), + }) + } + }, [navigation, params, renderBackButton]) + const navigateToNextScreen = useCallback(async () => { // Skip security setup if already enabled or already imported seed phrase if ( @@ -67,11 +91,7 @@ export function NotificationsSetupScreen({ navigation, route: { params } }: Prop }, [enableNotifications, navigateToNextScreen]) return ( - + diff --git a/apps/mobile/src/screens/Onboarding/SecuritySetupScreen.tsx b/apps/mobile/src/screens/Onboarding/SecuritySetupScreen.tsx index 1ce5957e73f..703ded63d20 100644 --- a/apps/mobile/src/screens/Onboarding/SecuritySetupScreen.tsx +++ b/apps/mobile/src/screens/Onboarding/SecuritySetupScreen.tsx @@ -3,7 +3,7 @@ import { BlurView } from 'expo-blur' import React, { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { ActivityIndicator, Alert, Image, Platform, StyleSheet } from 'react-native' -import { useDispatch } from 'react-redux' +import { useAppDispatch } from 'src/app/hooks' import { OnboardingStackParamList } from 'src/app/navigation/types' import { BiometricAuthWarningModal } from 'src/components/Settings/BiometricAuthWarningModal' import { enroll, tryLocalAuthenticate } from 'src/features/biometrics' @@ -26,8 +26,8 @@ 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' -import { opacify } from 'uniswap/src/utils/colors' import { isIOS } from 'utilities/src/platform' +import { opacify } from 'wallet/src/utils/colors' import { openSettings } from 'wallet/src/utils/linking' type Props = NativeStackScreenProps @@ -35,7 +35,7 @@ type Props = NativeStackScreenProps void }): JSX.Element { - const dispatch = useDispatch() + const dispatch = useAppDispatch() const activeAccountAddress = useActiveAccountAddressWithThrow() const onPressCopyAddress = async (): Promise => { @@ -98,7 +98,7 @@ function AccountCardItem({ onClose }: { onClose: () => void }): JSX.Element { export function ReceiveCryptoModal(): JSX.Element { const colors = useSporeColors() - const dispatch = useDispatch() + const dispatch = useAppDispatch() const { t } = useTranslation() const { initialState } = useAppSelector(selectModalState(ModalName.ReceiveCryptoModal)) diff --git a/apps/mobile/src/screens/SettingsAppearanceScreen.tsx b/apps/mobile/src/screens/SettingsAppearanceScreen.tsx index 0d8cfebeb58..bc0f5b4ca55 100644 --- a/apps/mobile/src/screens/SettingsAppearanceScreen.tsx +++ b/apps/mobile/src/screens/SettingsAppearanceScreen.tsx @@ -2,7 +2,7 @@ import { Action } from '@reduxjs/toolkit' import React from 'react' import { useTranslation } from 'react-i18next' import { SvgProps } from 'react-native-svg' -import { useDispatch } from 'react-redux' +import { useAppDispatch } from 'src/app/hooks' import { BackHeader } from 'src/components/layout/BackHeader' import { Screen } from 'src/components/layout/Screen' import { Flex, Text, TouchableArea, useSporeColors } from 'ui/src' @@ -60,7 +60,7 @@ interface AppearanceOptionProps { function AppearanceOption({ active, title, subtitle, Icon, option }: AppearanceOptionProps): JSX.Element { const colors = useSporeColors() - const dispatch = useDispatch() + const dispatch = useAppDispatch() const showCheckMark = active ? 1 : 0 diff --git a/apps/mobile/src/screens/SettingsBiometricAuthScreen.tsx b/apps/mobile/src/screens/SettingsBiometricAuthScreen.tsx index 436e62d703a..0435f5c303d 100644 --- a/apps/mobile/src/screens/SettingsBiometricAuthScreen.tsx +++ b/apps/mobile/src/screens/SettingsBiometricAuthScreen.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { Alert, ListRenderItemInfo } from 'react-native' import { FlatList } from 'react-native-gesture-handler' -import { useDispatch } from 'react-redux' +import { useAppDispatch } from 'src/app/hooks' import { BiometricAuthWarningModal } from 'src/components/Settings/BiometricAuthWarningModal' import { BackHeader } from 'src/components/layout/BackHeader' import { Screen } from 'src/components/layout/Screen' @@ -38,7 +38,7 @@ type BiometricPromptTriggerArgs = { export function SettingsBiometricAuthScreen(): JSX.Element { const { t } = useTranslation() - const dispatch = useDispatch() + const dispatch = useAppDispatch() const [showUnsafeWarningModal, setShowUnsafeWarningModal] = useState(false) const [unsafeWarningModalType, setUnsafeWarningModalType] = useState(null) diff --git a/apps/mobile/src/screens/SettingsCloudBackupPasswordConfirmScreen.tsx b/apps/mobile/src/screens/SettingsCloudBackupPasswordConfirmScreen.tsx index 9a7c3e4a61c..f150e6e04f1 100644 --- a/apps/mobile/src/screens/SettingsCloudBackupPasswordConfirmScreen.tsx +++ b/apps/mobile/src/screens/SettingsCloudBackupPasswordConfirmScreen.tsx @@ -1,10 +1,11 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack' -import React, { useCallback } from 'react' +import React from 'react' import { useTranslation } from 'react-i18next' +import { ScrollView } from 'react-native' import { SettingsStackParamList } from 'src/app/navigation/types' import { BackHeader } from 'src/components/layout/BackHeader' -import { SafeKeyboardScreen } from 'src/components/layout/SafeKeyboardScreen' -import { CloudBackupPassword } from 'src/features/CloudBackup/CloudBackupForm' +import { Screen } from 'src/components/layout/Screen' +import { CloudBackupPasswordForm } from 'src/features/CloudBackup/CloudBackupPasswordForm' import { Flex, Text } from 'ui/src' import { MobileScreens } from 'uniswap/src/types/screens/mobile' @@ -14,29 +15,19 @@ export function SettingsCloudBackupPasswordConfirmScreen({ navigation, route: { const { t } = useTranslation() const { password } = params - const navigateToNextScreen = useCallback((): void => { + const navigateToNextScreen = (): void => { navigation.navigate({ name: MobileScreens.SettingsCloudBackupProcessing, params, merge: true, }) - }, [navigation, params]) + } return ( - - - - - } - header={} - > - + + + + {t('onboarding.cloud.confirm.title')} @@ -44,8 +35,12 @@ export function SettingsCloudBackupPasswordConfirmScreen({ navigation, route: { {t('onboarding.cloud.confirm.description')} - - - + + + ) } diff --git a/apps/mobile/src/screens/SettingsCloudBackupPasswordCreateScreen.tsx b/apps/mobile/src/screens/SettingsCloudBackupPasswordCreateScreen.tsx index 3e98b749fbe..e386b1dfd2e 100644 --- a/apps/mobile/src/screens/SettingsCloudBackupPasswordCreateScreen.tsx +++ b/apps/mobile/src/screens/SettingsCloudBackupPasswordCreateScreen.tsx @@ -1,10 +1,11 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack' -import React, { useCallback, useState } from 'react' +import React, { useState } from 'react' import { useTranslation } from 'react-i18next' +import { ScrollView } from 'react-native' import { SettingsStackParamList } from 'src/app/navigation/types' import { BackHeader } from 'src/components/layout/BackHeader' -import { SafeKeyboardScreen } from 'src/components/layout/SafeKeyboardScreen' -import { CloudBackupPassword } from 'src/features/CloudBackup/CloudBackupForm' +import { Screen } from 'src/components/layout/Screen' +import { CloudBackupPasswordForm } from 'src/features/CloudBackup/CloudBackupPasswordForm' import { Button, Flex, Text, useSporeColors } from 'ui/src' import { OSDynamicCloudIcon } from 'ui/src/components/icons' import { BottomSheetModal } from 'uniswap/src/components/modals/BottomSheetModal' @@ -27,32 +28,23 @@ export function SettingsCloudBackupPasswordCreateScreen({ const [showCloudBackupInfoModal, setShowCloudBackupInfoModal] = useState(true) - const navigateToNextScreen = useCallback( - ({ password }: { password: string }): void => { - navigation.navigate({ - name: MobileScreens.SettingsCloudBackupPasswordConfirm, - params: { - password, - address, - }, - merge: true, - }) - }, - [navigation, address], - ) + const navigateToNextScreen = ({ password }: { password: string }): void => { + navigation.navigate({ + name: MobileScreens.SettingsCloudBackupPasswordConfirm, + params: { + password, + address, + }, + merge: true, + }) + } return ( - - - - - } - header={} - > - - + + + + + {t('settings.setting.backup.create.title', { cloudProviderName: getCloudProviderName(), })} @@ -63,7 +55,7 @@ export function SettingsCloudBackupPasswordCreateScreen({ })} - + {showCloudBackupInfoModal && ( @@ -93,7 +85,7 @@ export function SettingsCloudBackupPasswordCreateScreen({ )} - - + + ) } diff --git a/apps/mobile/src/screens/SettingsCloudBackupStatus.tsx b/apps/mobile/src/screens/SettingsCloudBackupStatus.tsx index b6db1368cfb..7222c295581 100644 --- a/apps/mobile/src/screens/SettingsCloudBackupStatus.tsx +++ b/apps/mobile/src/screens/SettingsCloudBackupStatus.tsx @@ -2,7 +2,7 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack' import React, { useState } from 'react' import { useTranslation } from 'react-i18next' import { Alert } from 'react-native' -import { useDispatch } from 'react-redux' +import { useAppDispatch } from 'src/app/hooks' import { SettingsStackParamList } from 'src/app/navigation/types' import { BackHeader } from 'src/components/layout/BackHeader' import { Screen } from 'src/components/layout/Screen' @@ -33,7 +33,7 @@ export function SettingsCloudBackupStatus({ }: Props): JSX.Element { const { t } = useTranslation() const colors = useSporeColors() - const dispatch = useDispatch() + const dispatch = useAppDispatch() const accounts = useAccounts() const mnemonicId = (accounts[address] as SignerMnemonicAccount)?.mnemonicId const backups = useCloudBackups(mnemonicId) diff --git a/apps/mobile/src/screens/SettingsFiatCurrencyModal.tsx b/apps/mobile/src/screens/SettingsFiatCurrencyModal.tsx index 8128ff32dad..9ed87f7be38 100644 --- a/apps/mobile/src/screens/SettingsFiatCurrencyModal.tsx +++ b/apps/mobile/src/screens/SettingsFiatCurrencyModal.tsx @@ -1,7 +1,7 @@ import React, { useCallback } from 'react' import { useTranslation } from 'react-i18next' -import { useDispatch } from 'react-redux' import { Action } from 'redux' +import { useAppDispatch } from 'src/app/hooks' import { VirtualizedList } from 'src/components/layout/VirtualizedList' import { closeModal } from 'src/features/modals/modalSlice' import { Flex, Text, TouchableArea, useSporeColors } from 'ui/src' @@ -13,7 +13,7 @@ import { useAppFiatCurrency, useFiatCurrencyInfo } from 'wallet/src/features/fia import { setCurrentFiatCurrency } from 'wallet/src/features/fiatCurrency/slice' export function SettingsFiatCurrencyModal(): JSX.Element { - const dispatch = useDispatch() + const dispatch = useAppDispatch() const { t } = useTranslation() return ( @@ -55,7 +55,7 @@ interface FiatCurrencyOptionProps { } function FiatCurrencyOption({ active, currency, onPress }: FiatCurrencyOptionProps): JSX.Element { - const dispatch = useDispatch() + const dispatch = useAppDispatch() const colors = useSporeColors() const { name, code } = useFiatCurrencyInfo(currency) diff --git a/apps/mobile/src/screens/SettingsScreen.tsx b/apps/mobile/src/screens/SettingsScreen.tsx index bc256a75f9e..da09e9bff34 100644 --- a/apps/mobile/src/screens/SettingsScreen.tsx +++ b/apps/mobile/src/screens/SettingsScreen.tsx @@ -5,6 +5,7 @@ import { Image, ListRenderItemInfo, SectionList, StyleSheet } from 'react-native import { FadeInDown, FadeOutUp } from 'react-native-reanimated' import { SvgProps } from 'react-native-svg' import { useDispatch } from 'react-redux' +import { useAppDispatch } from 'src/app/hooks' import { OnboardingStackNavigationProp, SettingsStackNavigationProp, @@ -74,7 +75,7 @@ import { export function SettingsScreen(): JSX.Element { const navigation = useNavigation() - const dispatch = useDispatch() + const dispatch = useAppDispatch() const colors = useSporeColors() const insets = useDeviceInsets() const { deviceSupportsBiometrics } = useBiometricContext() diff --git a/apps/mobile/src/screens/SettingsWallet.tsx b/apps/mobile/src/screens/SettingsWallet.tsx index 98e3e96bfcc..fcb90da361e 100644 --- a/apps/mobile/src/screens/SettingsWallet.tsx +++ b/apps/mobile/src/screens/SettingsWallet.tsx @@ -4,7 +4,7 @@ import React, { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { ListRenderItemInfo, SectionList } from 'react-native' import { SvgProps } from 'react-native-svg' -import { useDispatch } from 'react-redux' +import { useAppDispatch } from 'src/app/hooks' import { navigate } from 'src/app/navigation/rootNavigation' import { OnboardingStackNavigationProp, @@ -54,7 +54,7 @@ export function SettingsWallet({ params: { address }, }, }: Props): JSX.Element { - const dispatch = useDispatch() + const dispatch = useAppDispatch() const { t } = useTranslation() const colors = useSporeColors() const addressToAccount = useAccounts() diff --git a/apps/mobile/src/screens/SettingsWalletEdit.tsx b/apps/mobile/src/screens/SettingsWalletEdit.tsx index 1a01d92df45..4050c948f36 100644 --- a/apps/mobile/src/screens/SettingsWalletEdit.tsx +++ b/apps/mobile/src/screens/SettingsWalletEdit.tsx @@ -3,7 +3,7 @@ import React, { useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { Keyboard, KeyboardAvoidingView, TextInput as NativeTextInput, StyleSheet } from 'react-native' import { Gesture, GestureDetector } from 'react-native-gesture-handler' -import { useDispatch } from 'react-redux' +import { useAppDispatch } from 'src/app/hooks' import { SettingsStackParamList } from 'src/app/navigation/types' import { BackHeader } from 'src/components/layout/BackHeader' import { Screen } from 'src/components/layout/Screen' @@ -29,7 +29,7 @@ export function SettingsWalletEdit({ }, }: Props): JSX.Element { const { t } = useTranslation() - const dispatch = useDispatch() + const dispatch = useAppDispatch() const activeAccount = useAccounts()[address] const displayName = useDisplayName(address) const [nickname, setNickname] = useState(displayName?.name) diff --git a/apps/mobile/src/screens/SettingsWalletManageConnection.tsx b/apps/mobile/src/screens/SettingsWalletManageConnection.tsx index d24aa95ffee..a7e8212e68e 100644 --- a/apps/mobile/src/screens/SettingsWalletManageConnection.tsx +++ b/apps/mobile/src/screens/SettingsWalletManageConnection.tsx @@ -1,7 +1,7 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack' import React from 'react' import { SettingsStackParamList } from 'src/app/navigation/types' -import { ConnectedDappsList } from 'src/components/Requests/ConnectedDapps/ConnectedDappsList' +import { ConnectedDappsList } from 'src/components/WalletConnect/ConnectedDapps/ConnectedDappsList' import { Screen } from 'src/components/layout/Screen' import { useWalletConnect } from 'src/features/walletConnect/useWalletConnect' import { MobileScreens } from 'uniswap/src/types/screens/mobile' diff --git a/apps/mobile/src/screens/TokenDetailsScreen.tsx b/apps/mobile/src/screens/TokenDetailsScreen.tsx index f2518b5e759..9450be0e612 100644 --- a/apps/mobile/src/screens/TokenDetailsScreen.tsx +++ b/apps/mobile/src/screens/TokenDetailsScreen.tsx @@ -34,22 +34,22 @@ import { } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' import { fromGraphQLChain } from 'uniswap/src/features/chains/utils' import { PortfolioBalance } from 'uniswap/src/features/dataApi/types' -import { currencyIdToContractInput } from 'uniswap/src/features/dataApi/utils' import Trace from 'uniswap/src/features/telemetry/Trace' import { ModalName } from 'uniswap/src/features/telemetry/constants' -import TokenWarningModal from 'uniswap/src/features/tokens/TokenWarningModal' -import { CurrencyField } from 'uniswap/src/features/transactions/transactionState/types' import { UniverseChainId } from 'uniswap/src/types/chains' import { MobileScreens } from 'uniswap/src/types/screens/mobile' import { currencyIdToAddress, currencyIdToChain } from 'uniswap/src/utils/currencyId' import { NumberType } from 'utilities/src/format/types' import { useWalletNavigation } from 'wallet/src/contexts/WalletNavigationContext' import { isError, isNonPollingRequestInFlight } from 'wallet/src/data/utils' +import { currencyIdToContractInput } from 'wallet/src/features/dataApi/utils' import { useLocalizationContext } from 'wallet/src/features/language/LocalizationContext' import { Language } from 'wallet/src/features/language/constants' import { useCurrentLanguage } from 'wallet/src/features/language/hooks' import { useTokenContextMenu } from 'wallet/src/features/portfolio/useTokenContextMenu' +import TokenWarningModal from 'wallet/src/features/tokens/TokenWarningModal' import { useTokenWarningDismissed } from 'wallet/src/features/tokens/safetyHooks' +import { CurrencyField } from 'wallet/src/features/transactions/transactionState/types' function HeaderTitleElement({ data, @@ -249,7 +249,6 @@ function TokenDetails({ currencyId={_currencyId} currentChainBalance={currentChainBalance} data={data} - isBlocked={safetyLevel === SafetyLevel.Blocked} setEllipsisMenuVisible={setEllipsisMenuVisible} /> ) @@ -344,13 +343,11 @@ function TokenDetailsTextPlaceholders(): JSX.Element { function HeaderRightElement({ currencyId, currentChainBalance, - isBlocked, data, setEllipsisMenuVisible, }: { currencyId: string currentChainBalance: PortfolioBalance | null - isBlocked: boolean data?: TokenDetailsScreenQuery setEllipsisMenuVisible: (visible: boolean) => void }): JSX.Element { @@ -359,7 +356,6 @@ function HeaderRightElement({ const { menuActions, onContextMenuPress } = useTokenContextMenu({ currencyId, - isBlocked, tokenSymbolForNotification: data?.token?.symbol, portfolioBalance: currentChainBalance, }) diff --git a/apps/mobile/src/test/fixtures/explore.ts b/apps/mobile/src/test/fixtures/explore.ts index e07b29ee36e..ab7a4c5f05e 100644 --- a/apps/mobile/src/test/fixtures/explore.ts +++ b/apps/mobile/src/test/fixtures/explore.ts @@ -1,9 +1,9 @@ import { TokenItemData } from 'src/components/explore/TokenItem' import { Token } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' import { fromGraphQLChain } from 'uniswap/src/features/chains/utils' -import { createFixture } from 'uniswap/src/test/utils' import { UniverseChainId } from 'uniswap/src/types/chains' import { token } from 'wallet/src/test/fixtures' +import { createFixture } from 'wallet/src/test/utils' type TokenItemDataOptions = { token: Token | null diff --git a/apps/mobile/src/test/fixtures/redux.ts b/apps/mobile/src/test/fixtures/redux.ts index 6490a00aefe..cc92aacb098 100644 --- a/apps/mobile/src/test/fixtures/redux.ts +++ b/apps/mobile/src/test/fixtures/redux.ts @@ -2,10 +2,10 @@ import { PreloadedState } from 'redux' import { MobileState } from 'src/app/reducer' import { ModalsState } from 'src/features/modals/ModalsState' import { initialModalsState } from 'src/features/modals/modalSlice' -import { createFixture } from 'uniswap/src/test/utils' import { Account } from 'wallet/src/features/wallet/accounts/types' import { SharedState } from 'wallet/src/state/reducer' import { preloadedSharedState } from 'wallet/src/test/fixtures' +import { createFixture } from 'wallet/src/test/utils' export const preloadedModalsState = createFixture()(() => ({ ...initialModalsState, diff --git a/apps/mobile/src/test/render.tsx b/apps/mobile/src/test/render.tsx index 2ea941ef4c8..44bc8e3a359 100644 --- a/apps/mobile/src/test/render.tsx +++ b/apps/mobile/src/test/render.tsx @@ -13,14 +13,13 @@ import React, { PropsWithChildren } from 'react' import { MobileWalletNavigationProvider } from 'src/app/MobileWalletNavigationProvider' import { navigationRef } from 'src/app/navigation/NavigationContainer' import type { MobileState } from 'src/app/reducer' -import { store as appStore, persistedReducer } from 'src/app/store' +import type { AppStore } from 'src/app/store' +import { persistedReducer } from 'src/app/store' import { Resolvers } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' import { UnitagUpdaterContextProvider } from 'uniswap/src/features/unitags/context' import { SharedProvider } from 'wallet/src/provider' import { AutoMockedApolloProvider } from 'wallet/src/test/mocks/gql/provider' -type AppStore = typeof appStore - // This type extends the default options for render from RTL, as well // as allows the user to specify other things such as initialState, store. type ExtendedRenderOptions = RenderOptions & { diff --git a/apps/mobile/src/utils/reanimated.test.ts b/apps/mobile/src/utils/reanimated.test.ts index ac56b746912..57304b7909d 100644 --- a/apps/mobile/src/utils/reanimated.test.ts +++ b/apps/mobile/src/utils/reanimated.test.ts @@ -28,7 +28,7 @@ describe('reanimated numberToLocaleStringWorklet', function () { numberToLocaleStringWorklet(num, 'en-US', { style: 'currency', currency: 'USD', - }), + }) ).toBe('<$0.0000000000000001') }) @@ -39,7 +39,7 @@ describe('reanimated numberToLocaleStringWorklet', function () { numberToLocaleStringWorklet(num, 'en-US', { style: 'currency', currency: 'USD', - }), + }) ).toBe('$0.0000000123') }) @@ -121,49 +121,49 @@ describe('reanimated numberToLocaleStringWorklet', function () { numberToLocaleStringWorklet(num, 'en-US', { style, currency, - }), + }) ).toBe('$1,234.56') expect( numberToLocaleStringWorklet(negative_num, 'en-US', { style, currency, - }), + }) ).toBe('-$1,234.56') expect( numberToLocaleStringWorklet(num, 'de-DE', { style, currency, - }), + }) ).toBe('1.234,56 $') expect( numberToLocaleStringWorklet(num, 'hu', { style, currency: 'huf', - }), + }) ).toBe('1\u00A0234,56 Ft') expect( numberToLocaleStringWorklet(num, 'hu-HU', { style, currency: 'huf', - }), + }) ).toBe('1\u00A0234,56 Ft') expect( numberToLocaleStringWorklet(num, 'da-DK', { style, currency: 'DKK', - }), + }) ).toBe('1.234,56 kr') expect( numberToLocaleStringWorklet(num, 'nb-NO', { style, currency: 'NOK', - }), + }) ).toBe('1\u00A0234,56 kr') }) diff --git a/apps/mobile/src/utils/useAddBackButton.test.ts b/apps/mobile/src/utils/useAddBackButton.test.ts index 80d505dfb2a..4dbb1b5f514 100644 --- a/apps/mobile/src/utils/useAddBackButton.test.ts +++ b/apps/mobile/src/utils/useAddBackButton.test.ts @@ -13,7 +13,7 @@ describe(useAddBackButton, () => { index: 0, }), setOptions: setOptionsSpy, - } as unknown as NativeStackNavigationProp), + } as unknown as NativeStackNavigationProp) ) expect(setOptionsSpy).toHaveBeenCalled() @@ -26,7 +26,7 @@ describe(useAddBackButton, () => { index: 1, }), setOptions: setOptionsSpy, - } as unknown as NativeStackNavigationProp), + } as unknown as NativeStackNavigationProp) ) expect(setOptionsSpy).not.toHaveBeenCalled() diff --git a/apps/mobile/src/utils/useSagaStatus.ts b/apps/mobile/src/utils/useSagaStatus.ts index 2b8e6129ea2..02e6c1df5e6 100644 --- a/apps/mobile/src/utils/useSagaStatus.ts +++ b/apps/mobile/src/utils/useSagaStatus.ts @@ -1,12 +1,11 @@ import { useEffect } from 'react' -import { useDispatch } from 'react-redux' -import { useAppSelector } from 'src/app/hooks' +import { useAppDispatch, useAppSelector } from 'src/app/hooks' import { monitoredSagas } from 'src/app/saga' import { SagaState, SagaStatus } from 'wallet/src/utils/saga' // Convenience hook to get the status + error of an active saga export function useSagaStatus(sagaName: string, onSuccess?: () => void, resetSagaOnSuccess = true): SagaState { - const dispatch = useDispatch() + const dispatch = useAppDispatch() const sagaState = useAppSelector((s): SagaState | undefined => s.saga[sagaName]) if (!sagaState) { throw new Error(`No saga state found, is sagaName valid? Name: ${sagaName}`) diff --git a/apps/web/.eslintrc.js b/apps/web/.eslintrc.js index 713f9fe66e3..d3201301d82 100644 --- a/apps/web/.eslintrc.js +++ b/apps/web/.eslintrc.js @@ -58,10 +58,6 @@ module.exports = { 'error', { paths: [ - { - name: 'styled-components', - message: 'Styled components is deprecated, please use Flex or styled from "ui/src" instead.' - }, { name: 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks', importNames: ['usePortfolioBalancesQuery', 'usePortfolioBalancesWebLazyQuery'], diff --git a/apps/web/functions/components/metaTagInjector.test.ts b/apps/web/functions/components/metaTagInjector.test.ts index 9fd7689e7e0..4a3bf697fef 100644 --- a/apps/web/functions/components/metaTagInjector.test.ts +++ b/apps/web/functions/components/metaTagInjector.test.ts @@ -13,7 +13,7 @@ test('should append meta tag to element', () => { image: 'testImage', description: 'testDescription', }, - new Request('http://localhost'), + new Request('http://localhost') ) injector.appendProperty(element, property, content) expect(element.append).toHaveBeenCalledWith(``, { @@ -31,7 +31,7 @@ test('should append meta tag to element', () => { ``, { html: true, - }, + } ) expect(element.append).toHaveBeenCalledWith(``, { html: true, @@ -56,7 +56,7 @@ test('should append meta tag to element', () => { ``, { html: true, - }, + } ) expect(element.append).toHaveBeenCalledWith(``, { html: true, @@ -84,7 +84,7 @@ test('should pass through header blocked paths', () => { image: 'testImage', description: 'testDescription', }, - request, + request ) injector.element(element) expect(element.append).toHaveBeenCalledWith(``, { diff --git a/apps/web/functions/default.test.ts b/apps/web/functions/default.test.ts index 386f09113dc..293ccf78d85 100644 --- a/apps/web/functions/default.test.ts +++ b/apps/web/functions/default.test.ts @@ -6,7 +6,7 @@ test.each(defaultUrls)('should inject metadata for valid collections', async (de expect(body).toContain(` { expect(body).toContain(``) expect(body).toContain(``) expect(body).toContain( - ``, + `` ) expect(body).toContain(``) expect(body).toContain( - ``, + `` ) expect(body).toContain(``) expect(body).toContain( - ``, + `` ) }) diff --git a/apps/web/functions/nfts/asset/nft.test.ts b/apps/web/functions/nfts/asset/nft.test.ts index 4c7d659df8f..954c5d7c096 100644 --- a/apps/web/functions/nfts/asset/nft.test.ts +++ b/apps/web/functions/nfts/asset/nft.test.ts @@ -32,15 +32,15 @@ test.each(assets)('should inject metadata for valid assets', async (nft) => { expect(body).toContain(``) expect(body).toContain(``) expect(body).toContain( - ``, + `` ) expect(body).toContain(``) expect(body).toContain( - ``, + `` ) expect(body).toContain(``) expect(body).toContain( - ``, + `` ) }) diff --git a/apps/web/functions/nfts/collection/collection.test.ts b/apps/web/functions/nfts/collection/collection.test.ts index d05d2888e97..a5987294a8a 100644 --- a/apps/web/functions/nfts/collection/collection.test.ts +++ b/apps/web/functions/nfts/collection/collection.test.ts @@ -29,15 +29,15 @@ test.each([...collections])('should inject metadata for collections', async (col expect(body).toContain(``) expect(body).toContain(``) expect(body).toContain( - ``, + `` ) expect(body).toContain(``) expect(body).toContain( - ``, + `` ) expect(body).toContain(``) expect(body).toContain( - ``, + `` ) }) @@ -69,5 +69,5 @@ test.each([...invalidCollections, ...nonexistentCollections])( expect(body).not.toContain('twitter:title') expect(body).not.toContain('twitter:image') expect(body).not.toContain('twitter:image:alt') - }, + } ) diff --git a/apps/web/package.json b/apps/web/package.json index 859aa1f5a81..b494ba04d74 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -6,7 +6,7 @@ "scripts": { "ajv": "node scripts/compile-ajv-validators.js", "check:deps:usage": "depcheck", - "check:circular": "concurrently \"../../scripts/check-circular-imports.sh ./src/pages/App.tsx 7\" \"../../scripts/check-circular-imports.sh ./src/setupTests.ts 0\"", + "check:circular": "concurrently \"../../scripts/check-circular-imports.sh ./src/pages/App.tsx 6\" \"../../scripts/check-circular-imports.sh ./src/setupTests.ts 0\"", "sitemap:generate": "node scripts/generate-sitemap.js", "i18n:upload": "./scripts/crowdin.sh upload", "i18n:download": "./scripts/crowdin.sh download", @@ -172,8 +172,8 @@ "@reach/dialog": "0.10.5", "@reach/portal": "0.10.5", "@reduxjs/toolkit": "1.9.3", - "@rive-app/canvas": "2.19.0", - "@rive-app/react-canvas": "4.13.0", + "@rive-app/canvas": "2.8.3", + "@rive-app/react-canvas": "4.6.2", "@sentry/browser": "7.80.0", "@sentry/core": "7.80.0", "@sentry/react": "7.80.0", @@ -188,7 +188,7 @@ "@types/react-scroll-sync": "0.8.7", "@types/react-window-infinite-loader": "1.0.6", "@uniswap/analytics": "1.7.0", - "@uniswap/analytics-events": "2.34.0", + "@uniswap/analytics-events": "2.32.0", "@uniswap/governance": "1.0.2", "@uniswap/liquidity-staker": "1.0.2", "@uniswap/merkle-distributor": "1.0.1", diff --git a/apps/web/public/csp.json b/apps/web/public/csp.json index a6c42c61cd3..eda10105b3c 100644 --- a/apps/web/public/csp.json +++ b/apps/web/public/csp.json @@ -5,7 +5,6 @@ "scriptSrc": [ "'self'", "data:", - "'wasm-unsafe-eval'", "https://translate.googleapis.com/", "https://www.google-analytics.com", "https://www.googletagmanager.com" @@ -54,6 +53,7 @@ "https://bsc-dataseed1.bnbchain.org", "https://buy.moonpay.com/", "https://cdn.center.app/", + "https://cdn.jsdelivr.net/npm/@rive-app/canvas@2.8.3/rive.wasm", "https://celo-org.github.io", "https://cloudflare-eth.com", "https://cloudflare-ipfs.com", diff --git a/apps/web/public/images/extension_promo/announcement_modal_desktop.png b/apps/web/public/images/extension_promo/announcement_modal_desktop.png deleted file mode 100644 index c1778ee1662..00000000000 Binary files a/apps/web/public/images/extension_promo/announcement_modal_desktop.png and /dev/null differ diff --git a/apps/web/public/images/extension_promo/announcement_modal_mobile.png b/apps/web/public/images/extension_promo/announcement_modal_mobile.png deleted file mode 100644 index f0e413edcd3..00000000000 Binary files a/apps/web/public/images/extension_promo/announcement_modal_mobile.png and /dev/null differ diff --git a/apps/web/public/images/extension_promo/background_connector.png b/apps/web/public/images/extension_promo/background_connector.png deleted file mode 100644 index dddebd5ff1d..00000000000 Binary files a/apps/web/public/images/extension_promo/background_connector.png and /dev/null differ diff --git a/apps/web/public/index.html b/apps/web/public/index.html index 306ebf05863..35eec4bc21f 100644 --- a/apps/web/public/index.html +++ b/apps/web/public/index.html @@ -19,8 +19,6 @@ <% if (!process.env.REACT_APP_SKIP_CSP) { %> <% let cspConfig = require('./csp.json'); %> - - <% let cspStyleNonce = require('crypto').randomUUID().replaceAll('-','') %> <% if (process.env.REACT_APP_STAGING) { %> <% const cspDevConfig = require('./vercel-csp.json'); %> @@ -28,7 +26,7 @@ <% } %> <% } %> diff --git a/apps/web/public/nfts-sitemap.xml b/apps/web/public/nfts-sitemap.xml index c1c43d73ed0..5d053264215 100644 --- a/apps/web/public/nfts-sitemap.xml +++ b/apps/web/public/nfts-sitemap.xml @@ -2,682 +2,647 @@ https://app.uniswap.org/nfts/collection/0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x60e4d786628fea6478f785a6d7e704777c86a7c6 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0xed5af388653567af2f388e6224dc7c4b3241c544 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x34d85c9cdeb23fa97cb08333b511ac86e1c4e258 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x99a9b7c1116f9ceeb1652de04d5969cce509b069 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x49cf6f5d44e70224e2e23fdcdd2c053f30ada28b - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0xb7f7f6c52f2e2fdb1963eab30438024864c313f6 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x23581767a106ae21c074b2276d25e5c3e136a68b - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x8a90cab2b38dba80c64b7734e58ee1db38b8992e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0xba30e5f9bb24caa003e9f2f0497ad287fdf95623 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0xbd3531da5cf5857e7cfaa92426877b022e612cf8 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x7bd29408f11d2bfc23c34f18275bbf23bb716bc7 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x306b1ea3ecdf94ab739f1910bbda052ed4a9f949 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x1a92f7381b9f03921564a437210bb9396471050c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x5cc5b05a8a13e3fbdb0bb9fccd98d38e50f90c38 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x5af0d9827e0c53e4799bb226655a1de152a425a5 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x3bf2922f4520a8ba0c2efc3d2a1539678dad5e9d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0xe785e82358879f061bc3dcac6f0444462d4b5330 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x76be3b62873462d2142405439777e971754e8e77 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0xfd43af6d3fe1b916c026f6ac35b3ede068d1ca01 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x1cb1a5e65610aeff2551a50f76a87a7d3fb649c6 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0xff9c1b15b16263c61d017ee9f65c50e4ae0113d7 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x6339e5e072086621540d0362c4e3cea0d643e114 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0xb932a70a57673d89f4acffbe830e8ed7f75fb9e0 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x79fcdef22feed20eddacbb2587640e45491b757f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0xa3aee8bce55beea1951ef834b99f3ac60d1abeeb - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x769272677fab02575e84945f03eca517acc544cc - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x4db1f25d3d98600140dfc18deb7515be5bd293af - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x34eebee6942d8def3c125458d1a86e0a897fd6f9 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x59468516a8259058bad1ca5f8f4bff190d30e066 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x394e3d3044fc89fcdd966d3cb35ac0b32b0cda91 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x60bb1e2aa1c9acafb4d34f71585d7e959f387769 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x28472a58a490c5e09a238847f66a68a47cc76f0f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x341a1c534248966c4b6afad165b98daed4b964ef - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x82c7a8f707110f5fbb16184a5933e9f78a34c6ab - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0xccc441ac31f02cd96c153db6fd5fe0a2f4e6a68d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x764aeebcf425d56800ef2c84f2578689415a2daa - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x160c404b2b49cbc3240055ceaee026df1e8497a0 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0xd2f668a8461d6761115daf8aeb3cdf5f40c532c6 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x39ee2c7b3cb80254225884ca001f57118c8f21b6 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0xd774557b647330c91bf44cfeab205095f7e6c367 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x1792a96e5668ad7c167ab804a100ce42395ce54d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0xf87e31492faf9a91b02ee0deaad50d51d56d5d4d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x04afa589e2b933f9463c5639f412b183ec062505 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0xe75512aa3bec8f00434bbd6ad8b0a3fbff100ad6 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x348fc118bcc65a92dc033a951af153d14d945312 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x892848074ddea461a15f337250da3ce55580ca85 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x5946aeaab44e65eb370ffaa6a7ef2218cff9b47d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x282bdd42f4eb70e7a9d9f40c8fea0825b7f68c5d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x4b15a9c28034dc83db40cd810001427d3bd7163d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x7ea3cca10668b8346aec0bf1844a49e995527c8b - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0xb852c6b5892256c264cc2c888ea462189154d8d7 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x9378368ba6b85c1fba5b131b530f5f5bedf21a18 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x2acab3dea77832c09420663b0e1cb386031ba17b - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x0c2e57efddba8c768147d1fdf9176a0a6ebd5d83 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x08d7c0242953446436f34b4c78fe9da38c73668d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x8943c7bac1914c9a7aba750bf2b6b09fd21037e0 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x364c828ee171616a39897688a831c2499ad972ec - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x7f36182dee28c45de6072a34d29855bae76dbe2f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0xf61f24c2d93bf2de187546b14425bf631f28d6dc - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x797a48c46be32aafcedcfd3d8992493d8a1f256b - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x123b30e25973fecd8354dd5f41cc45a3065ef88c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x6632a9d63e142f17a668064d41a21193b49b41a0 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0xf4ee95274741437636e748ddac70818b4ed7d043 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x57a204aa1042f6e66dd7730813f4024114d74f37 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0xd1258db6ac08eb0e625b75b371c023da478e94a9 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x75e95ba5997eb235f40ecf8347cdb11f18ff640b - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0xd532b88607b1877fe20c181cba2550e3bbd6b31c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0xa1d4657e0e6507d5a94d06da93e94dc7c8c44b51 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0xedb61f74b0d09b2558f1eeb79b247c1f363ae452 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x7d8820fa92eb1584636f4f5b8515b5476b75171a - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x231d3559aa848bf10366fb9868590f01d34bf240 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0xad9fd7cb4fc7a0fbce08d64068f60cbde22ed34c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x0e9d6552b85be180d941f1ca73ae3e318d2d4f1f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0xb716600ed99b4710152582a124c697a7fe78adbf - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0xaadc2d4261199ce24a4b0a57370c4fcf43bb60aa - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x4e1f41613c9084fdb9e34e11fae9412427480e56 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x79986af15539de2db9a5086382daeda917a9cf0c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0xc99c679c50033bbc5321eb88752e89a93e9e83c5 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0xc36cf0cfcb5d905b8b513860db0cfe63f6cf9f5c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x9c8ff314c9bc7f6e59a9d9225fb22946427edc03 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x3110ef5f612208724ca51f5761a69081809f03b7 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x036721e5a769cc48b3189efbb9cce4471e8a48b1 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x524cab2ec69124574082676e6f654a18df49a048 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x7ab2352b1d2e185560494d5e577f9d3c238b78c5 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x32973908faee0bf825a343000fe412ebe56f802a - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x7daec605e9e2a1717326eedfd660601e2753a057 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0xc1caf0c19a8ac28c41fe59ba6c754e4b9bd54de9 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x33fd426905f149f8376e227d0c9d3340aad17af1 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x466cfcd0525189b573e794f554b8a751279213ac - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x6be69b2a9b153737887cfcdca7781ed1511c7e36 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x80336ad7a747236ef41f47ed2c7641828a480baa - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x9401518f4ebba857baa879d9f76e1cc8b31ed197 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x4b61413d4392c806e6d0ff5ee91e6073c21d6430 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0xc3f733ca98e0dad0386979eb96fb1722a1a05e69 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x09233d553058c2f42ba751c87816a8e9fae7ef10 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x960b7a6bcd451c9968473f7bbfd9be826efd549a - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x36d30b3b85255473d27dd0f7fd8f35e36a9d6f06 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x698fbaaca64944376e2cdc4cad86eaa91362cf54 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x497a9a79e82e6fc0ff10a16f6f75e6fcd5ae65a8 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x41a322b28d0ff354040e2cbc676f0320d8c8850d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0xa9c0a07a7cb84ad1f2ffab06de3e55aab7d523e8 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x942bc2d3e7a589fe5bd4a5c6ef9727dfd82f5c8a - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0xb47e3cd837ddf8e4c57f05d70ab865de6e193bbb - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x8821bee2ba0df28761afff119d66390d594cd280 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x8c6def540b83471664edc6d5cf75883986932674 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x8d9710f0e193d3f95c0723eaaf1a81030dc9116d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x86825dfca7a6224cfbd2da48e85df2fc3aa7c4b1 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x629a673a8242c2ac4b7b8c5d8735fbeac21a6205 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x9a534628b4062e123ce7ee2222ec20b86e16ca8f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0xc2c747e0f7004f9e8817db2ca4997657a7746928 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x73da73ef3a6982109c4d5bdb0db9dd3e3783f313 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0xc92ceddfb8dd984a89fb494c376f9a48b999aafc - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x3248e8ba90facc4fdd3814518c14f8cc4d980e4b - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x67d9417c9c3c250f61a83c7e8658dac487b56b09 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0xb6a37b5d14d502c3ab0ae6f3a0e058bc9517786e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x86c10d10eca1fca9daf87a279abccabe0063f247 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x4b3406a41399c7fd2ba65cbc93697ad9e7ea61e5 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0xb0640e8b5f24bedc63c33d371923d68fde020303 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0xd3d9ddd0cf0a5f0bfb8f7fceae075df687eaebab - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0xa5c0bd78d1667c13bfb403e2a3336871396713c5 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x4d7d2e237d64d1484660b55c0a4cc092fa5e6716 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0xfcb1315c4273954f74cb16d5b663dbf479eec62e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x66d1db16101502ed0ca428842c619ca7b62c8fef - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x128675d4fddbc4a0d3f8aa777d8ee0fb8b427c2f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x19b86299c21505cdf59ce63740b240a9c822b5e4 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0xacf63e56fd08970b43401492a02f6f38b6635c91 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0x0bebad1ff25c623dff9605dad4a8f782d5da37df - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 https://app.uniswap.org/nfts/collection/0xdceaf1652a131f32a821468dc03a92df0edd86ea - 2024-07-05T19:43:14.783Z - 0.7 - - - https://app.uniswap.org/nfts/collection/0x273f7f8e6489682df756151f5525576e322d51a3 - 2024-07-05T19:43:14.783Z - 0.7 - - - https://app.uniswap.org/nfts/collection/0x77372a4cc66063575b05b44481f059be356964a4 - 2024-07-05T19:43:14.783Z - 0.7 - - - https://app.uniswap.org/nfts/collection/0xf5b0a3efb8e8e4c201e2a935f110eaaf3ffecb8d - 2024-07-05T19:43:14.783Z - 0.7 - - - https://app.uniswap.org/nfts/collection/0x22c36bfdcef207f9c0cc941936eff94d4246d14a - 2024-07-05T19:43:14.783Z - 0.7 - - - https://app.uniswap.org/nfts/collection/0x59325733eb952a92e069c87f0a6168b29e80627f - 2024-07-05T19:43:14.783Z - 0.7 - - - https://app.uniswap.org/nfts/collection/0x0e3a2a1f2146d86a604adc220b4967a898d7fe07 - 2024-07-05T19:43:14.783Z - 0.7 - - - https://app.uniswap.org/nfts/collection/0x3af2a97414d1101e2107a70e7f33955da1346305 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.7 \ No newline at end of file diff --git a/apps/web/public/pools-sitemap.xml b/apps/web/public/pools-sitemap.xml index a63ab564281..0903b0778ae 100644 --- a/apps/web/public/pools-sitemap.xml +++ b/apps/web/public/pools-sitemap.xml @@ -2,5652 +2,4357 @@ https://app.uniswap.org/explore/pools/ethereum/0xcbcdf9626bc03e24f779434178a73a0b4bad62ed - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x88e6a0c2ddd26feeb64f039a2c41296fcb3f5640 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x8ad599c3a0ff1de082011efddc58f1908eb6e6d8 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x4e68ccd3e89f51c3074ca5072bbac773960dfa36 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x4585fe77225b41b697c938b018e2ac67ac5a20c0 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0xc63b0708e2f7e69cb8a1df0e1389a98c35a76d52 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x99ac8ca7087fa4a2a1fb6357269965a2014abc35 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x11b815efb8f581194ae79006d24e0d814b7697f6 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0xa6cc3c2531fdaa6ae1a3ca84c2855806728693e8 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x5777d92f208679db4b9778590fa3cab3ac9e2168 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x1d42064fc4beb5f8aaf85f4617ae8b3b5b8bd801 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0xc2e9f25be6257c210d7adf0d4cd6e3e881ba25f8 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x11950d141ecb863f01007add7d1a342041227b58 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0xc5c134a1f112efa96003f8559dba6fac0ba77692 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x109830a1aaad605bbf02a9dfa7b0b92ec2fb7daa - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x1df4c6e36d61416813b42fe32724ef11e363eddc - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x12d6867fa648d269835cf69b49f125147754b54d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x3416cf6c708da44db2624d63ea0aaef7113527c6 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0xe8c6c9227491c0a8156a0106a0204d881bb7e531 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x04708077eca6bb527a5bbbd6358ffb043a9c1c14 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x9db9e0e53058c89e5b94e29621a205198648425b - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0xf239009a101b6b930a527deaab6961b6e7dec8a6 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0xfe0df74636bc25c7f2400f22fe7dae32d39443d2 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0xf4c5e0f4590b6679b3030d29a84857f226087fef - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x5764a6f2212d502bc5970f9f129ffcd61e5d7563 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0xa3f558aebaecaf0e11ca4b2199cc5ed341edfd74 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x99132b53ab44694eeb372e87bced3929e4ab8456 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x6c6bc977e13df9b0de53b251522280bb72383700 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x9d96880952b4c80a55099b9c258250f2cc5813ec - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x3afdc5e6dfc0b0a507a8e023c9dce2cafc310316 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x290a6a7460b308ee3f19023d2d00de604bcf5b42 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0xac4b3dacb91461209ae9d41ec517c2b9cb1b7daf - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x60594a405d53811d3bc4766596efd80fd545a270 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x331399c614ca67dee86733e5a2fba40dbb16827c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x4b5ab61593a2401b1075b90c04cbcdd3f87ce011 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x844eb5c280f38c7462316aad3f338ef9bda62668 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0xe936f0073549ad8b1fa53583600d629ba9375161 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x2f62f2b4c5fcd7570a709dec05d68ea19c82a9ec - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x381fe4eb128db1621647ca00965da3f9e09f4fac - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x97e7d56a0408570ba1a7852de36350f7713906ec - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0xcd423f3ab39a11ff1d9208b7d37df56e902c932b - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0xe15e6583425700993bd08f51bf6e7b73cd5da91b - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x69d91b94f0aaf8e8a2586909fa77a5c2c89818d5 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0xe42318ea3b998e8355a3da364eb9d48ec725eb45 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0xad9ef19e289dcbc9ab27b83d2df53cdeff60f02d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x3b685307c8611afb2a9e83ebc8743dc20480716e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x7bea39867e4169dbe237d55c8242a8f2fcdcc387 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x7b1e5d984a43ee732de195628d20d05cfabc3cc7 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x7858e59e0c01ea06df3af3d20ac7b0003275d4bf - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0xae2a25cbdb19d0dc0dddd1d2f6b08a6e48c4a9a9 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x21b8065d10f73ee2e260e5b47d3344d3ced7596e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x0d4a11d5eeaac28ec3f61d100daf4d40471f1852 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x517f9dd285e75b599234f7221227339478d0fcc8 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0xb4e16d0168e52d35cacd2c6185b44281ec28c9dc - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0xa43fe16908251ee70ef74718545e4fe6c5ccec9f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x0af81cd5d9c124b4859d65697a4cd10ee223746a - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0xca7c2771d248dcbe09eabe0ce57a62e18da178c0 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x09d1d767edf8fa23a64c51fa559e0688e526812f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x7b73644935b8e68019ac6356c40661e1bc315860 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x180efc1349a69390ade25667487a826164c9c6e4 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x9c4fe5ffd9a9fc5678cfbd93aa2d4fd684b67c4c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0xa478c2975ab1ea89e8196811f51a7b7ade33eb11 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0xbb2b8038a1640196fbe3e38816f3e67cba72d940 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x9ec9367b8c4dd45ec8e7b800b1f719251053ad60 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0xc91ef786fbf6d62858262c82c63de45085dea659 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x197d7010147df7b99e9025c724f13723b29313f8 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x25647e01bd0967c1b9599fa3521939871d1d0888 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x2f0b1417aa42ebf0b4ca1154212847f6094d708d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x6ada49aeccf6e556bb7a35ef0119cc8ca795294a - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x2a6c340bcbb0a79d3deecd3bc5cbc2605ea9259f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0xda2d09fbbf8ee4b5051a0e9b562c5fcb4b393b18 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x48d20b3e529fb3dd7d91293f80638df582ab2daa - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x4028daac072e492d34a3afdbef0ba7e35d8b55c4 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0xc2eab7d33d3cb97692ecb231a5d0e4a649cb539d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0xc5be99a02c6857f9eac67bbce58df5572498f40c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0xe4b8583ccb95b25737c016ac88e539d0605949e8 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x8dbee21e8586ee356130074aaa789c33159921ca - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x43de4318b6eb91a7cf37975dbb574396a7b5b5c6 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x9ff68f61ca5eb0c6606dc517a9d44001e564bb66 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0xa29fe6ef9592b5d408cca961d0fb9b1faf497d6d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x1b1137dd16faa651e38a9dfb5d9ffff7767fdf62 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x470e8de2ebaef52014a47cb5e6af86884947f08c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x8fb8e9921922d2ffb529a95d28a0d06d275d7a59 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0xd3d2e2692501a5c9ca623199d38826e513033a17 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x97e1fcb93ae7267dbafad23f7b9afaa08264cfd8 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0xa5e9c917b4b821e4e0a5bbefce078ab6540d6b5e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x2cc846fff0b08fb3bffad71f53a60b4b6e6d6482 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x959873fb4fc11825fba83c80c4c632db1e936e15 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0xa7480aafa8ad2af3ce24ac6853f960ae6ac7f0c4 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0xc7e6b676bfc73ae40bcc4577f22aab1682c691c6 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x570febdf89c07f256c75686caca215289bb11cfc - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x343fd171caf4f0287ae6b87d75a8964dc44516ab - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0xcaa004418eb42cdf00cb057b7c9e28f0ffd840a5 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0xe3d3551bb608e7665472180a20280630d9e938aa - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0xb6b0c651c37ec4ca81c0a128420e02001a57fac2 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x4e34da137f0b317c633838458e0c923a5e088752 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0xfe9e7931e55c514c33d489c88582fa36e84bd8e3 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x5281e311734869c64ca60ef047fd87759397efe6 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x149148acc3b06b8cc73af3a10e84189243a35925 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x8ef79d6c328c25da633559c20c75f638a4863462 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x14af1804dbbf7d621ecc2901eef292a24a0260ea - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x80a9ae39310abf666a87c743d6ebbd0e8c42158e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0xc31e54c7a869b9fcbecc14363cf510d1c41fa443 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x2f5e87c9312fa29aed5c179e456625d79015299c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0xc6962004f452be9203591991d15f6b388e09e8d0 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0xc6f780497a95e246eb9449f5e4770916dcd6396a - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x641c00a822e8b671738d32a431a4fb6074e5c79d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x92c63d0e701caae670c9415d91c474f686298f00 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x1aeedd3727a6431b8f070c0afaa81cc74f273882 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0xcda53b1f66614552f834ceef361a8d12a0b8dad8 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x35218a1cbac5bbc3e57fd9bd38219d37571b3537 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x17c14d2c404d167802b16c450d3c99f88f2c4f4d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x468b88941e7cc0b88c1869d68ab6b570bcef62ff - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0xdbaeb7f0dfe3a0aafd798ccecb5b22e708f7852c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x149e36e72726e0bcea5c59d40df2c43f60f5a22d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0xbaaf1fc002e31cb12b99e4119e5e350911ec575b - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0xa67f72f21bd9f91db2da2d260590da5e6c437009 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x92fd143a8fa0c84e016c2765648b9733b0aa519e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x7cf803e8d82a50504180f417b8bc7a493c0a0503 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x81c48d31365e6b526f6bbadc5c9aafd822134863 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x446bf9748b4ea044dd759d9b9311c70491df8f29 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0xc82819f72a9e77e2c0c3a69b3196478f44303cf4 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x50c7390dfdd3756139e6efb5a461c2eb7331ceb4 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x1dfc1054e0e2a10e33c9ca21aad5aa8a1cce91e3 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0xc91b7b39bbb2c733f0e7459348fd0c80259c8471 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x59d72ddb29da32847a4665d08ffc8464a7185fae - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x09ba302a3f5ad2bf8853266e271b005a5b3716fe - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0xa77d77c9773c35e910acc2e30cefe52b54a58414 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x8da66e470403b3d3eee66c67e2c61fda6e248ad1 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x2f020e708811c054f146eebcc4d5a215fd4eec26 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x7e7fb3cceca5f2ac952edf221fd2a9f62e411980 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x68c685fd52a56f04665b491d491355a624540e85 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0xa8328bf492ba1b77ad6381b3f7567d942b000baf - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0xc0cf0f380ddb44dbcaf19a86d094c8bba3efa04a - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0xa169d1ab5c948555954d38700a6cdaa7a4e0c3a0 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x1862200e8e7ce1c0827b792d0f9546156f44f892 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x05bbaaa020ff6bea107a9a1e06d2feb7bfd79ed2 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0xd02a4969dc12bb889754361f8bcf3385ac1b2077 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0xc24f7d8e51a64dc1238880bd00bb961d54cbeb29 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x7c06736e41236fecd681dd3353aa77ecd19ea565 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0xc473e2aee3441bf9240be85eb122abb059a3b57c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x14353445c8329df76e6f15e9ead18fa2d45a8bb6 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x2039f8c9cd32ba9cd2ea7e575d5b1abea93f7527 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0xd3e11119d2680c963f1cdcffece0c4ade823fb58 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x8e295789c9465487074a65b1ae9ce0351172393f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x97bca422ec0ee4851f2110ea743c1cd0a14835a1 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0xbe3ad6a5669dc0b8b12febc03608860c31e2eef6 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x56ebd63a756b94d3de9cea194896b4920b64fb01 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0xe2ddd33585b441b9245085588169f35108f85a6e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x84436a2af97f37018db116ae8e1b691666db3d00 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x21b8065d10f73ee2e260e5b47d3344d3ced7596e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x0d4a11d5eeaac28ec3f61d100daf4d40471f1852 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x517f9dd285e75b599234f7221227339478d0fcc8 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0xb4e16d0168e52d35cacd2c6185b44281ec28c9dc - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0xa43fe16908251ee70ef74718545e4fe6c5ccec9f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x0af81cd5d9c124b4859d65697a4cd10ee223746a - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0xca7c2771d248dcbe09eabe0ce57a62e18da178c0 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x09d1d767edf8fa23a64c51fa559e0688e526812f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x7b73644935b8e68019ac6356c40661e1bc315860 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x180efc1349a69390ade25667487a826164c9c6e4 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x9c4fe5ffd9a9fc5678cfbd93aa2d4fd684b67c4c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0xa478c2975ab1ea89e8196811f51a7b7ade33eb11 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0xbb2b8038a1640196fbe3e38816f3e67cba72d940 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x9ec9367b8c4dd45ec8e7b800b1f719251053ad60 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0xc91ef786fbf6d62858262c82c63de45085dea659 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x197d7010147df7b99e9025c724f13723b29313f8 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x25647e01bd0967c1b9599fa3521939871d1d0888 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x2f0b1417aa42ebf0b4ca1154212847f6094d708d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x6ada49aeccf6e556bb7a35ef0119cc8ca795294a - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x2a6c340bcbb0a79d3deecd3bc5cbc2605ea9259f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0xda2d09fbbf8ee4b5051a0e9b562c5fcb4b393b18 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x48d20b3e529fb3dd7d91293f80638df582ab2daa - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x4028daac072e492d34a3afdbef0ba7e35d8b55c4 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0xc2eab7d33d3cb97692ecb231a5d0e4a649cb539d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0xc5be99a02c6857f9eac67bbce58df5572498f40c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0xe4b8583ccb95b25737c016ac88e539d0605949e8 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x8dbee21e8586ee356130074aaa789c33159921ca - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x43de4318b6eb91a7cf37975dbb574396a7b5b5c6 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x9ff68f61ca5eb0c6606dc517a9d44001e564bb66 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0xa29fe6ef9592b5d408cca961d0fb9b1faf497d6d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x1b1137dd16faa651e38a9dfb5d9ffff7767fdf62 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x470e8de2ebaef52014a47cb5e6af86884947f08c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x8fb8e9921922d2ffb529a95d28a0d06d275d7a59 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0xd3d2e2692501a5c9ca623199d38826e513033a17 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x97e1fcb93ae7267dbafad23f7b9afaa08264cfd8 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0xa5e9c917b4b821e4e0a5bbefce078ab6540d6b5e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x2cc846fff0b08fb3bffad71f53a60b4b6e6d6482 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x959873fb4fc11825fba83c80c4c632db1e936e15 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0xa7480aafa8ad2af3ce24ac6853f960ae6ac7f0c4 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0xc7e6b676bfc73ae40bcc4577f22aab1682c691c6 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x570febdf89c07f256c75686caca215289bb11cfc - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x343fd171caf4f0287ae6b87d75a8964dc44516ab - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0xcaa004418eb42cdf00cb057b7c9e28f0ffd840a5 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0xe3d3551bb608e7665472180a20280630d9e938aa - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0xb6b0c651c37ec4ca81c0a128420e02001a57fac2 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x4e34da137f0b317c633838458e0c923a5e088752 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0xfe9e7931e55c514c33d489c88582fa36e84bd8e3 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x5281e311734869c64ca60ef047fd87759397efe6 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x149148acc3b06b8cc73af3a10e84189243a35925 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x8ef79d6c328c25da633559c20c75f638a4863462 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x68f5c0a2de713a54991e01858fd27a3832401849 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x4533bad2dc588f0fadf8d2e72386d4cd6a19b519 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x85149247691df622eaf1a8bd0cafd40bc45154a9 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x0392b358ce4547601befa962680bede836606ae2 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x1c3140ab59d6caf9fa7459c6f83d4b52ba881d36 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0xd1f1bad4c9e6c44dec1e9bf3b94902205c5cd6c3 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x03af20bdaaffb4cc0a521796a223f7d85e2aac31 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x73b14a78a0d396c521f954532d43fd5ffe385216 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0xac85eaf55e9c60ed40a683de7e549d23fdfbeb33 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x04f6c85a1b00f6d9b75f91fd23835974cc07e65c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x730691cdac3cbd4d41fc5eb9d8abbb0cea795b94 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x535541f1aa08416e69dc4d610131099fa2ae7222 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0xfc1f3296458f9b2a27a0b91dd7681c4020e09d05 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x85c31ffa3706d1cce9d525a00f1c7d4a2911754c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0xd52533a3309b393afebe3176620e8ccfb6159f8a - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0xff7fbdf7832ae524deda39ca402e03d92adff7a5 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0xb589969d38ce76d3d7aa319de7133bc9755fd840 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0xf334f6104a179207ddacfb41fa3567feea8595c2 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x1fb3cf6e48f1e7b10213e7b6d87d4c073c7fdb7b - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0xd4344ea0c5ade7e22b9b275f0bde7a145dec5a23 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x5b42a63d6741416ce9a7b9f4f16d8c9231ccddd4 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x252cbdff917169775be2b552ec9f6781af95e7f6 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x2ab22ac86b25bd448a4d9dc041bd2384655299c4 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0xc858a329bf053be78d6239c4a4343b8fbd21472b - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0xa73c628eaf6e283e26a7b1f8001cf186aa4c0e8e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0xb533c12fb4e7b53b5524eab9b47d93ff6c7a456f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x2ae3d6096d8215ac2acddf30c60caa984ea5debe - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x19ea026886cbb7a900ecb2458636d72b5cae223b - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x6f32061f59a21086c334d0d45f804089ce374aaf - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0xfaf037caafa9620bfaebc04c298bf4a104963613 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0xadb35413ec50e0afe41039eac8b930d313e94fa4 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0xe9e3893921de87b1194a8108f9d70c24bde71c27 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0xf1f199342687a7d78bcc16fce79fa2665ef870e1 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0xf44acaa38be5e965c5ddf374e7a2ba270e580684 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x36e42931a765022790b797963e42c5522d6b585a - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x5adba6c5589c50791dd65131df29677595c7efa7 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x3249e3e3e4133ee18e65347daf586610cc265f54 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0xca1b837c87c6563910c2befa48834fa2a8c3d72d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x6ef7b14bcd8d989cef8f8ec8ba4bf371b2ac95fd - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x37ffd11972128fd624337ebceb167c8c0a5115ff - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0xe62bd99a9501ca33d98913105fc2bec5bae6e5dd - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0xb2ac2e5a3684411254d58b1c5a542212b782114d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0xb0efaf46a1de55c54f333f93b1f0641e73bc16d0 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0xd0fa3b5264ccde31e8b094b86bca4a1e97d3c603 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0xad4c666fc170b468b19988959eb931a3676f0e9f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x790fde1fd6d2568050061a88c375d5c2e06b140b - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0xaefc1edaede6adadcdf3bb344577d45a80b19582 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0xa8a5356ee5d02fe33d72355e4f698782f8f199e8 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x55bc964fe3b0c8cc2d4c63d65f1be7aef9bb1a3c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x95d9d28606ee55de7667f0f176ebfc3215cfd9c0 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x21b8065d10f73ee2e260e5b47d3344d3ced7596e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x0d4a11d5eeaac28ec3f61d100daf4d40471f1852 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x517f9dd285e75b599234f7221227339478d0fcc8 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0xb4e16d0168e52d35cacd2c6185b44281ec28c9dc - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0xa43fe16908251ee70ef74718545e4fe6c5ccec9f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x0af81cd5d9c124b4859d65697a4cd10ee223746a - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0xca7c2771d248dcbe09eabe0ce57a62e18da178c0 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x09d1d767edf8fa23a64c51fa559e0688e526812f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x7b73644935b8e68019ac6356c40661e1bc315860 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x180efc1349a69390ade25667487a826164c9c6e4 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x9c4fe5ffd9a9fc5678cfbd93aa2d4fd684b67c4c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0xa478c2975ab1ea89e8196811f51a7b7ade33eb11 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0xbb2b8038a1640196fbe3e38816f3e67cba72d940 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x9ec9367b8c4dd45ec8e7b800b1f719251053ad60 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0xc91ef786fbf6d62858262c82c63de45085dea659 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x197d7010147df7b99e9025c724f13723b29313f8 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x25647e01bd0967c1b9599fa3521939871d1d0888 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x2f0b1417aa42ebf0b4ca1154212847f6094d708d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x6ada49aeccf6e556bb7a35ef0119cc8ca795294a - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x2a6c340bcbb0a79d3deecd3bc5cbc2605ea9259f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0xda2d09fbbf8ee4b5051a0e9b562c5fcb4b393b18 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x48d20b3e529fb3dd7d91293f80638df582ab2daa - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x4028daac072e492d34a3afdbef0ba7e35d8b55c4 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0xc2eab7d33d3cb97692ecb231a5d0e4a649cb539d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0xc5be99a02c6857f9eac67bbce58df5572498f40c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0xe4b8583ccb95b25737c016ac88e539d0605949e8 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x8dbee21e8586ee356130074aaa789c33159921ca - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x43de4318b6eb91a7cf37975dbb574396a7b5b5c6 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x9ff68f61ca5eb0c6606dc517a9d44001e564bb66 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0xa29fe6ef9592b5d408cca961d0fb9b1faf497d6d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x1b1137dd16faa651e38a9dfb5d9ffff7767fdf62 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x470e8de2ebaef52014a47cb5e6af86884947f08c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x8fb8e9921922d2ffb529a95d28a0d06d275d7a59 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0xd3d2e2692501a5c9ca623199d38826e513033a17 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x97e1fcb93ae7267dbafad23f7b9afaa08264cfd8 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0xa5e9c917b4b821e4e0a5bbefce078ab6540d6b5e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x2cc846fff0b08fb3bffad71f53a60b4b6e6d6482 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x959873fb4fc11825fba83c80c4c632db1e936e15 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0xa7480aafa8ad2af3ce24ac6853f960ae6ac7f0c4 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0xc7e6b676bfc73ae40bcc4577f22aab1682c691c6 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x570febdf89c07f256c75686caca215289bb11cfc - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x343fd171caf4f0287ae6b87d75a8964dc44516ab - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0xcaa004418eb42cdf00cb057b7c9e28f0ffd840a5 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0xe3d3551bb608e7665472180a20280630d9e938aa - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0xb6b0c651c37ec4ca81c0a128420e02001a57fac2 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x4e34da137f0b317c633838458e0c923a5e088752 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0xfe9e7931e55c514c33d489c88582fa36e84bd8e3 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x5281e311734869c64ca60ef047fd87759397efe6 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x149148acc3b06b8cc73af3a10e84189243a35925 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x8ef79d6c328c25da633559c20c75f638a4863462 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x45dda9cb7c25131df268515131f647d726f50608 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x50eaedb835021e4a108b7290636d62e9765cc6d7 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x167384319b41f7094e62f7506409eb38079abff8 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0xa374094527e1673a86de625aa59517c5de346d32 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x86f1d8390222a3691c28938ec7404a1661e618e0 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0xeda1094f59a4781456734e5d258b95e6be20b983 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x847b64f9d3a95e977d157866447a5c0a5dfa0ee5 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x94ab9e4553ffb839431e37cc79ba8905f45bfbea - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x0e44ceb592acfc5d3f09d996302eb4c499ff8c10 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x1e5bd2ab4c308396c06c182e1b7e7ba8b2935b83 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x9b08288c3be4f62bbf8d1c20ac9c5e6f9467d8b7 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0xb6e57ed85c4c9dbfef2a68711e9d6f36c56e0fcb - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x3e31ab7f37c048fc6574189135d108df80f0ea26 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0xd36ec33c8bed5a9f7b6630855f1533455b98a418 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0xdac8a8e6dbf8c690ec6815e0ff03491b2770255d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0xfe343675878100b344802a6763fd373fdeed07a4 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x0a28c2f5e0e8463e047c203f00f649812ae67e4f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x88f3c15523544835ff6c738ddb30995339ad57d6 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x98b9162161164de1ed182a0dfa08f5fbf0f733ca - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0xeef1a9507b3d505f0062f2be9453981255b503c8 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0xc4c06c9a239f94fc0a1d3e04d23c159ebe8316f1 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x849ec65748107aedc518dbc42961f358ea1361a7 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x2db87c4831b2fec2e35591221455834193b50d1b - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0xa4d8c89f0c20efbe54cba9e7e7a7e509056228d9 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x642f28a89fa9d0fa30e664f71804bfdd7341d21f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x2aceda63b5e958c45bd27d916ba701bc1dc08f7a - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x781067ef296e5c4a4203f81c593274824b7c185d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x4ccd010148379ea531d6c587cfdd60180196f9b1 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0xd866fac7db79994d08c0ca2221fee08935595b4b - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x941061770214613ba0ca3db9a700c39587bb89b6 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0xa9077cdb3d13f45b8b9d87c43e11bce0e73d8631 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0xa01f64fa1b923dd9c5c7618b39a6ba8098a88863 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0xa830ff28bb7a46570a7e43dc24a35a663b9cfc2e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x8837a61644d523cbe5216dde226f8f85e3aa9be3 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0xca5d44977d6de1846530eb434167b208752fba7d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x4d05f2a005e6f36633778416764e82d1d12e7fbb - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x41e64a5bc929fa8e6a9c8d7e3b81a13b21ff3045 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x3ea34cfc9322273311f7843826a2581c4a00fd39 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x785061ed819414dc4269d2a5d5974069c0daea96 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x3f5228d0e7d75467366be7de2c31d0d098ba2c23 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x2e3f22e9a1c2470b2e293351f48c99e1fd788f32 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x2a08c38c7e1fa969325e2b64047abb085dec3756 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0xe6c36eed27c2e8ecb9a233bf12da06c9730b5955 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0xefa98fdf168f372e5e9e9b910fcdfd65856f3986 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x76fa081e510f43ac8335efdb4db88c9ff1894413 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0xc6832ef0af793336aa44a936e54b992bff47e7cd - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x865f456479a21e2b3d866561d7171a3d0a7b112d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0xbd934a7778771a7e2d9bf80596002a214d8c9304 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x9ab9f658104467604b5afa9a3e1df62f35f7b208 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x6e430d59ba145c59b73a6db674fe3d53c1f31cae - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x21b8065d10f73ee2e260e5b47d3344d3ced7596e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x0d4a11d5eeaac28ec3f61d100daf4d40471f1852 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x517f9dd285e75b599234f7221227339478d0fcc8 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0xb4e16d0168e52d35cacd2c6185b44281ec28c9dc - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0xa43fe16908251ee70ef74718545e4fe6c5ccec9f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x0af81cd5d9c124b4859d65697a4cd10ee223746a - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0xca7c2771d248dcbe09eabe0ce57a62e18da178c0 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x09d1d767edf8fa23a64c51fa559e0688e526812f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x7b73644935b8e68019ac6356c40661e1bc315860 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x180efc1349a69390ade25667487a826164c9c6e4 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x9c4fe5ffd9a9fc5678cfbd93aa2d4fd684b67c4c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0xa478c2975ab1ea89e8196811f51a7b7ade33eb11 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0xbb2b8038a1640196fbe3e38816f3e67cba72d940 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x9ec9367b8c4dd45ec8e7b800b1f719251053ad60 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0xc91ef786fbf6d62858262c82c63de45085dea659 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x197d7010147df7b99e9025c724f13723b29313f8 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x25647e01bd0967c1b9599fa3521939871d1d0888 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x2f0b1417aa42ebf0b4ca1154212847f6094d708d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x6ada49aeccf6e556bb7a35ef0119cc8ca795294a - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x2a6c340bcbb0a79d3deecd3bc5cbc2605ea9259f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0xda2d09fbbf8ee4b5051a0e9b562c5fcb4b393b18 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x48d20b3e529fb3dd7d91293f80638df582ab2daa - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x4028daac072e492d34a3afdbef0ba7e35d8b55c4 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0xc2eab7d33d3cb97692ecb231a5d0e4a649cb539d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0xc5be99a02c6857f9eac67bbce58df5572498f40c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0xe4b8583ccb95b25737c016ac88e539d0605949e8 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x8dbee21e8586ee356130074aaa789c33159921ca - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x43de4318b6eb91a7cf37975dbb574396a7b5b5c6 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x9ff68f61ca5eb0c6606dc517a9d44001e564bb66 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0xa29fe6ef9592b5d408cca961d0fb9b1faf497d6d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x1b1137dd16faa651e38a9dfb5d9ffff7767fdf62 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x470e8de2ebaef52014a47cb5e6af86884947f08c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x8fb8e9921922d2ffb529a95d28a0d06d275d7a59 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0xd3d2e2692501a5c9ca623199d38826e513033a17 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x97e1fcb93ae7267dbafad23f7b9afaa08264cfd8 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0xa5e9c917b4b821e4e0a5bbefce078ab6540d6b5e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x2cc846fff0b08fb3bffad71f53a60b4b6e6d6482 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x959873fb4fc11825fba83c80c4c632db1e936e15 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0xa7480aafa8ad2af3ce24ac6853f960ae6ac7f0c4 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0xc7e6b676bfc73ae40bcc4577f22aab1682c691c6 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x570febdf89c07f256c75686caca215289bb11cfc - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x343fd171caf4f0287ae6b87d75a8964dc44516ab - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0xcaa004418eb42cdf00cb057b7c9e28f0ffd840a5 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0xe3d3551bb608e7665472180a20280630d9e938aa - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0xb6b0c651c37ec4ca81c0a128420e02001a57fac2 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x4e34da137f0b317c633838458e0c923a5e088752 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0xfe9e7931e55c514c33d489c88582fa36e84bd8e3 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x5281e311734869c64ca60ef047fd87759397efe6 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x149148acc3b06b8cc73af3a10e84189243a35925 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x8ef79d6c328c25da633559c20c75f638a4863462 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x9e37cb775a047ae99fc5a24dded834127c4180cd - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x48413707b70355597404018e7c603b261fcadf3f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xade9bcd4b968ee26bed102dd43a55f6a8c2416df - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xda679706ff21114ac9fac5198bff24543f357a16 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xba3f945812a83471d709bce9c3ca699a19fb46f7 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xc9034c3e7f58003e6ae0c8438e7c8f4598d5acaa - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x4c36388be6f416a29c8d8eee81c771ce6be14b18 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xa1b2457c0b627f97f6cc892946a382451e979014 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x4b0aaf3ebb163dd45f663b38b6d93f6093ebc2d3 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xae2ce200bdb67c472030b31f602f0756c9aeb61c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x3bc5180d5439b500f381f9a46f15dd6608101671 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x5122e02898ece3bc62df8c1efdb29a9e914244d3 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x24e1cbd6fed006ceed9af0dce688acc7951d57a9 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x2556230ac694093d4d3b7b965a2f2d77d4c403a4 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xdaca082c2c7d052a96fa83ea9d3a7b6839e39586 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xa555149210075702a734968f338d5e1cbd509354 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x10648ba41b8565907cfa1496765fa4d95390aa0d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x00bcec1526dae1e170a53017b8775a93b7810d7c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x20e068d76f9e90b90604500b84c7e19dcb923e7e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x6b93950a9b589bc32b82a5df4e5148f98a7fae27 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xd9caa6dbe6791fcb7fc9fb59d1a6b3dd8c1c2339 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x62e81e93136ac42a1ada48d4098f5f9e703e7455 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x84206d33845c9d811438b6fe4e7a0c634748dc50 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xd0b53d9277642d899df5c87a3966a349a798f224 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xcfa7c4bb565915f1c4f9475e2a0536d31efad776 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xa7de21f28ca460b45373b217cd4eb111c3faeff8 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xb64dff20dd5c47e6dbb56ead80d23568006dec1e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xad4e969f4193878e5cc89cefb57faf6c7c0048da - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xdf5eb97e3e23ca7f5a5fd2264680377c211310ba - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xf16baaae8eb7b37f4280e72924479f69e7a61f32 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xe745a591970e0fa981204cf525e170a2b9e4fb93 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x64b74c66b9ba60ca668b781289767ae7298f37ae - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x17e1ebd791e7253a5e606fd94c5b66c14d873136 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x46715bd57b9ec01deadb35fe096fb44acda79414 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x3447accd4b8e735329d1065244aad2ed630f0122 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x2feb7f3ffc243f7de94d5ea5975533d301584e07 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x0d5959a52e7004b601f0be70618d01ac3cdce976 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x2170ca774e48a3f51559917ada6f9d7ae8f7bfea - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x62a76dfa8951aefcff787e790782db3633ebf422 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x8073679e0b3b2d1d665777cf1b2b5b1c2d3d2d0c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x143f1a6f3fb32e6ab3f22d3cc6b417b5c2197599 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x82ad659c2f152aad59bb37cbc5e7663a2de0c607 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xa4efe9e8e2a2d5a2ac46805f233b8e49d0e11955 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xfcc89a1f250d76de198767d33e1ca9138a7fb54b - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x2faa2b42b782d578a160f61bb7cd763a17476730 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xdd44c0e83c2570062d1e6fdd440b4724862e8f31 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xe3930a14641786e123e7bbe842d701fa1cbfe2df - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x6d03360ce4764e862ed81660c1f76cc2711b14b6 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xc055f66f228105072315247785c00299d0ce27e8 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xcae1d141ab11cef0a415cf0440025e1e5e962e06 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x21b8065d10f73ee2e260e5b47d3344d3ced7596e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x0d4a11d5eeaac28ec3f61d100daf4d40471f1852 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x517f9dd285e75b599234f7221227339478d0fcc8 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xb4e16d0168e52d35cacd2c6185b44281ec28c9dc - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xa43fe16908251ee70ef74718545e4fe6c5ccec9f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x0af81cd5d9c124b4859d65697a4cd10ee223746a - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xca7c2771d248dcbe09eabe0ce57a62e18da178c0 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x09d1d767edf8fa23a64c51fa559e0688e526812f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x7b73644935b8e68019ac6356c40661e1bc315860 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x180efc1349a69390ade25667487a826164c9c6e4 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x9c4fe5ffd9a9fc5678cfbd93aa2d4fd684b67c4c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xa478c2975ab1ea89e8196811f51a7b7ade33eb11 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xbb2b8038a1640196fbe3e38816f3e67cba72d940 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x9ec9367b8c4dd45ec8e7b800b1f719251053ad60 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xc91ef786fbf6d62858262c82c63de45085dea659 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x197d7010147df7b99e9025c724f13723b29313f8 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x25647e01bd0967c1b9599fa3521939871d1d0888 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x2f0b1417aa42ebf0b4ca1154212847f6094d708d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x6ada49aeccf6e556bb7a35ef0119cc8ca795294a - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x2a6c340bcbb0a79d3deecd3bc5cbc2605ea9259f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xda2d09fbbf8ee4b5051a0e9b562c5fcb4b393b18 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x48d20b3e529fb3dd7d91293f80638df582ab2daa - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x4028daac072e492d34a3afdbef0ba7e35d8b55c4 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xc2eab7d33d3cb97692ecb231a5d0e4a649cb539d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xc5be99a02c6857f9eac67bbce58df5572498f40c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xe4b8583ccb95b25737c016ac88e539d0605949e8 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x8dbee21e8586ee356130074aaa789c33159921ca - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x43de4318b6eb91a7cf37975dbb574396a7b5b5c6 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x9ff68f61ca5eb0c6606dc517a9d44001e564bb66 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xa29fe6ef9592b5d408cca961d0fb9b1faf497d6d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x1b1137dd16faa651e38a9dfb5d9ffff7767fdf62 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x470e8de2ebaef52014a47cb5e6af86884947f08c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x8fb8e9921922d2ffb529a95d28a0d06d275d7a59 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xd3d2e2692501a5c9ca623199d38826e513033a17 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x97e1fcb93ae7267dbafad23f7b9afaa08264cfd8 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xa5e9c917b4b821e4e0a5bbefce078ab6540d6b5e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x2cc846fff0b08fb3bffad71f53a60b4b6e6d6482 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x959873fb4fc11825fba83c80c4c632db1e936e15 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xa7480aafa8ad2af3ce24ac6853f960ae6ac7f0c4 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xc7e6b676bfc73ae40bcc4577f22aab1682c691c6 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x570febdf89c07f256c75686caca215289bb11cfc - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x343fd171caf4f0287ae6b87d75a8964dc44516ab - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xcaa004418eb42cdf00cb057b7c9e28f0ffd840a5 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xe3d3551bb608e7665472180a20280630d9e938aa - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xb6b0c651c37ec4ca81c0a128420e02001a57fac2 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x4e34da137f0b317c633838458e0c923a5e088752 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xfe9e7931e55c514c33d489c88582fa36e84bd8e3 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x5281e311734869c64ca60ef047fd87759397efe6 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x149148acc3b06b8cc73af3a10e84189243a35925 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x8ef79d6c328c25da633559c20c75f638a4863462 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x0f338ec12d3f7c3d77a4b9fcc1f95f3fb6ad0ea6 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x4eaa90264d6a3567228dcb5cfc242200da586437 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x6fe9e9de56356f7edbfcbb29fab7cd69471a4869 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0xf420603317a0996a3fce1b1a80993eaef6f7ae1a - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x47a90a2d92a8367a91efa1906bfc8c1e05bf10c4 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x41bf5eeae051fbd2e97b76b5f8f0fdcc1a1e526b - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x28df0835942396b7a1b7ae1cd068728e6ddbbafd - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0xa3f3664a52f01b42557524bd14556e379daf5669 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x1fd22fa7274bafebdfb1881321709f1219744829 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0xe39cfc1a2e51a09ecbd060a24ee4eef5a97697bb - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x06396509195eb9e07c38a016694dc9ff535b128a - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x5a1c486edefda2f09d3b349fadc38524f1743826 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x5bf1cf153c102a79d9e18b7fb7c79ba57fa70d0c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x2c3c320d49019d4f9a92352e947c7e5acfe47d68 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x4141325bac36affe9db165e854982230a14e6d48 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x17507bef4c3abc1bc715be723ee1baf571256e05 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x8149b92ea743cc382aada523b68b8834733b9015 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0xc98f01bf2141e1140ef8f8cad99d4b021d10718f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x7f9d307973cdabe42769d9712df8ee1cc1a28d10 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x5c87da28a45e5089b762dcbbd86f743d14c54317 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x2cd97604ef77bbcb1fa0cff47545dff8ec7def08 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x7862d9b4be2156b15d54f41ee4ede2d5b0b455e4 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x554548b404213c7efcdbab933f52edfe3c581834 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x63008c5ea4e47f5421e0e1428b1c5043a507d0d0 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x0350ca994791c4b07a5b02b08aaf9d6fc8ab510e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x32776ed4d96ed069a2d812773f0ad8ad9ef83cf8 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x84f3ca9b7a1579ff74059bd0e8929424d3fa330e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x5289a8dbf7029ee0b0498a84777ed3941d9acfec - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0xb2bc284ab4c953b7f7a06d59c0ceb2de26405f22 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x508acf810857fefa86281499068ad5d19ebce325 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0xccdfcd1aac447d5b29980f64b831c532a6a33726 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x4fb87838a29b37598099ef5aa6b3fbeeef987c50 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x515e94dc736b9d8b7d28ecf1cece0aba3d75da97 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0xfd6e5b7c30538dff2752058e425ad01a56b831cc - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0xcb99fe720124129520f7a09ca3cbef78d58ed934 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0xd2f21358c1549be193537b2a4c5dc7f0228ae011 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x93094ed1c907e4bca7eb041cb659da94f7e1b58e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0xd37e6ecb991d1a0e7610c89666817665713362a7 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x73234630bd159384c8d43f145407312d64614f43 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0xad1ddf00c4ae50573e4dc98e6c5ee93baa04a0c4 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0xa765593c821f7df9ad81119509a37961e7ffa6c5 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x9b501a7ad3087d603ceb34424b7b2a6c348ad0b7 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0xafebb7cfa1a15fcac4121b609b456cbce3137c20 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x0adaf134ae0c4583b3a38fc3168a83e33162651e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0xf9878a5dd55edc120fde01893ea713a4f032229c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x84e47c7f2fe86f6b5efbe14fee46b8bb871b2e05 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0xf3e5bec78654049990965f666b0612e116b94fb2 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x33e59edd3214e97cb68450c6d3d6c167de072aba - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x2ca76c7e466e560e0cb11a91269bb953e41254bc - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0xbb124e35ab9e85f8d59ba83500e559dc052b9368 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x21b8065d10f73ee2e260e5b47d3344d3ced7596e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x0d4a11d5eeaac28ec3f61d100daf4d40471f1852 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x517f9dd285e75b599234f7221227339478d0fcc8 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0xb4e16d0168e52d35cacd2c6185b44281ec28c9dc - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0xa43fe16908251ee70ef74718545e4fe6c5ccec9f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x0af81cd5d9c124b4859d65697a4cd10ee223746a - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0xca7c2771d248dcbe09eabe0ce57a62e18da178c0 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x09d1d767edf8fa23a64c51fa559e0688e526812f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x7b73644935b8e68019ac6356c40661e1bc315860 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x180efc1349a69390ade25667487a826164c9c6e4 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x9c4fe5ffd9a9fc5678cfbd93aa2d4fd684b67c4c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0xa478c2975ab1ea89e8196811f51a7b7ade33eb11 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0xbb2b8038a1640196fbe3e38816f3e67cba72d940 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x9ec9367b8c4dd45ec8e7b800b1f719251053ad60 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0xc91ef786fbf6d62858262c82c63de45085dea659 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x197d7010147df7b99e9025c724f13723b29313f8 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x25647e01bd0967c1b9599fa3521939871d1d0888 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x2f0b1417aa42ebf0b4ca1154212847f6094d708d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x6ada49aeccf6e556bb7a35ef0119cc8ca795294a - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x2a6c340bcbb0a79d3deecd3bc5cbc2605ea9259f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0xda2d09fbbf8ee4b5051a0e9b562c5fcb4b393b18 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x48d20b3e529fb3dd7d91293f80638df582ab2daa - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x4028daac072e492d34a3afdbef0ba7e35d8b55c4 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0xc2eab7d33d3cb97692ecb231a5d0e4a649cb539d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0xc5be99a02c6857f9eac67bbce58df5572498f40c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0xe4b8583ccb95b25737c016ac88e539d0605949e8 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x8dbee21e8586ee356130074aaa789c33159921ca - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x43de4318b6eb91a7cf37975dbb574396a7b5b5c6 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x9ff68f61ca5eb0c6606dc517a9d44001e564bb66 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0xa29fe6ef9592b5d408cca961d0fb9b1faf497d6d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x1b1137dd16faa651e38a9dfb5d9ffff7767fdf62 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x470e8de2ebaef52014a47cb5e6af86884947f08c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x8fb8e9921922d2ffb529a95d28a0d06d275d7a59 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0xd3d2e2692501a5c9ca623199d38826e513033a17 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x97e1fcb93ae7267dbafad23f7b9afaa08264cfd8 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0xa5e9c917b4b821e4e0a5bbefce078ab6540d6b5e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x2cc846fff0b08fb3bffad71f53a60b4b6e6d6482 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x959873fb4fc11825fba83c80c4c632db1e936e15 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0xa7480aafa8ad2af3ce24ac6853f960ae6ac7f0c4 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0xc7e6b676bfc73ae40bcc4577f22aab1682c691c6 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x570febdf89c07f256c75686caca215289bb11cfc - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x343fd171caf4f0287ae6b87d75a8964dc44516ab - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0xcaa004418eb42cdf00cb057b7c9e28f0ffd840a5 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0xe3d3551bb608e7665472180a20280630d9e938aa - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0xb6b0c651c37ec4ca81c0a128420e02001a57fac2 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x4e34da137f0b317c633838458e0c923a5e088752 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0xfe9e7931e55c514c33d489c88582fa36e84bd8e3 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x5281e311734869c64ca60ef047fd87759397efe6 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x149148acc3b06b8cc73af3a10e84189243a35925 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x8ef79d6c328c25da633559c20c75f638a4863462 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0xd88d5f9e6c10e6febc9296a454f6c2589b1e8fae - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0xb90fe7da36ac89448e6dfd7f2bb1e90a66659977 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0xbd6313d0796984c578cae6bc5b5e23b27c5540c5 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x1f18cd7d1c7ba0dbe3d9abe0d3ec84ce1ad10066 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x7da99753ff017f1b7afb2c8c0542718dc9f15f21 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x079e7a44f42e9cd2442c3b9536244be634e8f888 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x1c8dafd358d308b880f71edb5170b010b106ca60 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0xbd0f6f34baa3c1329448a69bab90111a20756f01 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x3420720e561f3082f1e514a4545f0f2e0c955a5d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0xea3fb6e3313a2a90757e4ca3d6749efd0107b0b6 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0xf130f72f8190f662522774c3367e6e8814f5e219 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x4a46c053bd5c10a959aea258228217b9d3405f3d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0xb83258bf5940c98abf54f26c5a02710bd6b83b2c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x6a209c5329f0a225fa1890d4177823c096016f34 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0xdb24905b1b080f65dedb0ad978aad5c76363d3c6 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0xddff2cdad11898b901a661e32e9fa010780263a0 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x72dd8fe09b5b493012e5816068dfc6fb26a2a9e6 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x54fc722a66abfb6500a36d8b7b2646129d0e836a - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x53b612b32233c80ec439a64325a29766ce95be7f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0xe5edcbe72d1bc223097a1bed1fe6c0e404b4290c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0xb928c37b8bd9754d321dc3d3c6ef374d332fe761 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x2d70cbabf4d8e61d5317b62cbe912935fd94e0fe - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x953e2937f0515c43ca7995e80c84aedcbbb9385e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x84394d80830ae963b599ded7d9149b90059f182f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0xa1777e082fa1746eb78dd9c1fbb515419cf6e538 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x112466c8b6e5abe42c78c47eb1b9d40baa3f943c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x9491d57c5687ab75726423b55ac2d87d1cda2c3f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x978799f1845c00c9a4d9fd2629b9ce18df66e488 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0xdc55d1fd1c04e005051a40bd59c5f95623257bc5 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x34757893070b0fc5de37aaf2844255ff90f7f1e0 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x7faf167615419228f3f7d71d52d840dab154913c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0xa4d7b6a50dd4c55334ca6f175dbc6561f269d264 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x0ed413cefde954d8e5c54d981d7d182b587e98e3 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x524375d0c6a04439128428f400b00eae81a2e9e4 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x4b7a4530d56ff55a4dce089d917ede812e543307 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x84bb5b9bf1b6782c87cfa3e396f2f571c8e49646 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x723292eea7e1576ae482a5c317934054c0199e24 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x9b42940e8184d866aac6595a91f8d8952a59d3b9 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x37622453c614f625d288151101ffe48fd222ced1 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x4a94130b9e8eb0a0959c2c0f1ee9583213773fd9 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x51514b3dc24afc1db95586242b99f0063bea17c5 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0xc130254e9196d48bbd9f91240390a6e8203132e9 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x60ac25da2ada3be14a2a8c04e45b072bed965966 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x4e392a3883a84225260ff857318517eb50e5d128 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0xca0aa06385a42242fe9523cd7015f6d01cd8f6b2 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x3e448c17043ce1481bbe53c0fd19481bad8b98a6 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x81060e6bf2a683f208b8799a33c7c09830cabed1 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x463fe9f646b61ccfb43a022bf947075411cd71c7 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x21b8065d10f73ee2e260e5b47d3344d3ced7596e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x0d4a11d5eeaac28ec3f61d100daf4d40471f1852 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x517f9dd285e75b599234f7221227339478d0fcc8 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0xb4e16d0168e52d35cacd2c6185b44281ec28c9dc - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0xa43fe16908251ee70ef74718545e4fe6c5ccec9f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x0af81cd5d9c124b4859d65697a4cd10ee223746a - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0xca7c2771d248dcbe09eabe0ce57a62e18da178c0 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x09d1d767edf8fa23a64c51fa559e0688e526812f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x7b73644935b8e68019ac6356c40661e1bc315860 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x180efc1349a69390ade25667487a826164c9c6e4 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x9c4fe5ffd9a9fc5678cfbd93aa2d4fd684b67c4c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0xa478c2975ab1ea89e8196811f51a7b7ade33eb11 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0xbb2b8038a1640196fbe3e38816f3e67cba72d940 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x9ec9367b8c4dd45ec8e7b800b1f719251053ad60 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0xc91ef786fbf6d62858262c82c63de45085dea659 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x197d7010147df7b99e9025c724f13723b29313f8 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x25647e01bd0967c1b9599fa3521939871d1d0888 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x2f0b1417aa42ebf0b4ca1154212847f6094d708d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x6ada49aeccf6e556bb7a35ef0119cc8ca795294a - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x2a6c340bcbb0a79d3deecd3bc5cbc2605ea9259f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0xda2d09fbbf8ee4b5051a0e9b562c5fcb4b393b18 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x48d20b3e529fb3dd7d91293f80638df582ab2daa - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x4028daac072e492d34a3afdbef0ba7e35d8b55c4 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0xc2eab7d33d3cb97692ecb231a5d0e4a649cb539d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0xc5be99a02c6857f9eac67bbce58df5572498f40c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0xe4b8583ccb95b25737c016ac88e539d0605949e8 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x8dbee21e8586ee356130074aaa789c33159921ca - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x43de4318b6eb91a7cf37975dbb574396a7b5b5c6 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x9ff68f61ca5eb0c6606dc517a9d44001e564bb66 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0xa29fe6ef9592b5d408cca961d0fb9b1faf497d6d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x1b1137dd16faa651e38a9dfb5d9ffff7767fdf62 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x470e8de2ebaef52014a47cb5e6af86884947f08c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x8fb8e9921922d2ffb529a95d28a0d06d275d7a59 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0xd3d2e2692501a5c9ca623199d38826e513033a17 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x97e1fcb93ae7267dbafad23f7b9afaa08264cfd8 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0xa5e9c917b4b821e4e0a5bbefce078ab6540d6b5e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x2cc846fff0b08fb3bffad71f53a60b4b6e6d6482 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x959873fb4fc11825fba83c80c4c632db1e936e15 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0xa7480aafa8ad2af3ce24ac6853f960ae6ac7f0c4 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0xc7e6b676bfc73ae40bcc4577f22aab1682c691c6 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x570febdf89c07f256c75686caca215289bb11cfc - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x343fd171caf4f0287ae6b87d75a8964dc44516ab - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0xcaa004418eb42cdf00cb057b7c9e28f0ffd840a5 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0xe3d3551bb608e7665472180a20280630d9e938aa - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0xb6b0c651c37ec4ca81c0a128420e02001a57fac2 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x4e34da137f0b317c633838458e0c923a5e088752 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0xfe9e7931e55c514c33d489c88582fa36e84bd8e3 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x5281e311734869c64ca60ef047fd87759397efe6 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x149148acc3b06b8cc73af3a10e84189243a35925 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x8ef79d6c328c25da633559c20c75f638a4863462 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x0f23d49bc92ec52ff591d091b3e16c937034496e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x0f23d49bc92ec52ff591d091b3e16c937034496e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0xbf16ef186e715668aa29cef57e2fd7f9d48adfe6 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x0f23d49bc92ec52ff591d091b3e16c937034496e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x5645dcb64c059aa11212707fbf4e7f984440a8cf - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x0f23d49bc92ec52ff591d091b3e16c937034496e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x3ad4913fa896391c9822a81d8d869cc0d783bdd7 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x0f23d49bc92ec52ff591d091b3e16c937034496e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x0f23d49bc92ec52ff591d091b3e16c937034496e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x0f23d49bc92ec52ff591d091b3e16c937034496e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x7a415b19932c0105c82fdb6b720bb01b0cc2cae3 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x9b3423373e6e786c9ac367120533abe4ee398373 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x4a25dbdf9629b1782c3e2c7de3bdce41f1c7f801 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0xbe80225f09645f172b079394312220637c440a63 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x059615ebf32c946aaab3d44491f78e4f8e97e1d3 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x435664008f38b0650fbc1c9fc971d0a3bc2f1e47 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x4b62fa30fea125e43780dc425c2be5acb4ba743b - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0xc3db44adc1fcdfd5671f555236eae49f4a8eea18 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0xddd23787a6b80a794d952f5fb036d0b31a8e6aff - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0xa86aca6d7c393c06dcdc30473ea3d1b05c358dff - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x1ffec7119e315b15852557f654ae0052f76e6ae1 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x0f027d40c80d8f70f77d3884776531f80b21d20e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x69c66beafb06674db41b22cfc50c34a93b8d82a2 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0xeedff72a683058f8ff531e8c98575f920430fdc5 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x811cfb75567a252bea23474e2ccd1286927bfe0a - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x2caccf71bdf8fff97c06a46eca29b611b1a74b5e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0xf07a84f0732dfe8eea0d3961bcd8f62c761ff508 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/ethereum/0x8c1c499b1796d7f3c2521ac37186b52de024e58c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0xe5cf22ee4988d54141b77050967e1052bd9c7f7a - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x7f580f8a02b759c350e6b8340e7c2d4b8162b6a9 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x48b0ab72c2591849e678e7d6f272b75ef9b863f7 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x74d0ae8b8e1fca6039707564704a25ad2ee036b0 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x5969efdde3cf5c0d9a88ae51e47d721096a97203 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0xe32efff8f8b5fdc53803405aa3f623f03f8a8767 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0xe8629b6a488f366d27dad801d1b5b445199e2ada - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x066b28f0c160935cf285f75ed600967bf8417035 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0xddd23787a6b80a794d952f5fb036d0b31a8e6aff - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0xa86aca6d7c393c06dcdc30473ea3d1b05c358dff - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x1ffec7119e315b15852557f654ae0052f76e6ae1 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x0f027d40c80d8f70f77d3884776531f80b21d20e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x69c66beafb06674db41b22cfc50c34a93b8d82a2 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0xeedff72a683058f8ff531e8c98575f920430fdc5 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x811cfb75567a252bea23474e2ccd1286927bfe0a - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x2caccf71bdf8fff97c06a46eca29b611b1a74b5e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0xf07a84f0732dfe8eea0d3961bcd8f62c761ff508 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/arbitrum/0x8c1c499b1796d7f3c2521ac37186b52de024e58c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x146b020399769339509c98b7b353d19130c150ec - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0xd28f71e383e93c570d3edfe82ebbceb35ec6c412 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0xadab76dd2dca7ae080a796f0ce86170e482afb4a - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x0fb07e6d6e1f52c839608e1436d2ea810cf07257 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0xddd23787a6b80a794d952f5fb036d0b31a8e6aff - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0xa86aca6d7c393c06dcdc30473ea3d1b05c358dff - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x1ffec7119e315b15852557f654ae0052f76e6ae1 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x0f027d40c80d8f70f77d3884776531f80b21d20e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x69c66beafb06674db41b22cfc50c34a93b8d82a2 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0xeedff72a683058f8ff531e8c98575f920430fdc5 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x811cfb75567a252bea23474e2ccd1286927bfe0a - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x2caccf71bdf8fff97c06a46eca29b611b1a74b5e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0xf07a84f0732dfe8eea0d3961bcd8f62c761ff508 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/optimism/0x8c1c499b1796d7f3c2521ac37186b52de024e58c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x95d2483d2a0fff034004f91c53d649623d993896 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x19c5505638383337d2972ce68b493ad78e315147 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0xc143161ed3ed8049bb63d8da42907c08a10e2269 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0xc3286373599dd5af2a17a572ebb7561f05f88bec - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0xbb98b3d2b18aef63a3178023a920971cf5f29be4 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x647fb01a63de9a551b39c7915693b25e6bcec502 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0xa90c1c009dc8292bd04ced30f9b53a5ff7a806a0 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0xddd23787a6b80a794d952f5fb036d0b31a8e6aff - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0xa86aca6d7c393c06dcdc30473ea3d1b05c358dff - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x1ffec7119e315b15852557f654ae0052f76e6ae1 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x0f027d40c80d8f70f77d3884776531f80b21d20e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x69c66beafb06674db41b22cfc50c34a93b8d82a2 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0xeedff72a683058f8ff531e8c98575f920430fdc5 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x811cfb75567a252bea23474e2ccd1286927bfe0a - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x2caccf71bdf8fff97c06a46eca29b611b1a74b5e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0xf07a84f0732dfe8eea0d3961bcd8f62c761ff508 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/polygon/0x8c1c499b1796d7f3c2521ac37186b52de024e58c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xfb765ff72a14735550f1d798a5efd1311f2ddee7 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x3537f2a5f99f08f59eb1417073db1fadbebf0c74 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xde8ed0277ee0e84c25756a73ffa7374e4aeadf46 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xd8f3a72d2b2220a5067abe8c38aea57dc2d69a5e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x7ec18abf80e865c6799069df91073335935c4185 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x14b1911dd6b451c2771661ae8cd70637d726c356 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x9ae8084c21752971d867597c07f2673765d949a1 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xcfaf75a3d292c3535ea3acdb16ed2ee58c2bb091 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x8055e6de251e414e8393b20adab096afb3cf8399 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xffec10fe1355c2d8df4f62affcdeffdb04f06569 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xc16454420f100b2e771d8bc4c5b6200068129a34 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x046f405e4ae1d0e786eda4959adadbd417d13ad8 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xeccb34691c06c1c9c31ceb2228b22cbd242b5879 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xe22a2dfaaaaec8a7b2b7acb4909eaaa5c5bd6e64 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xe2dda0911e227e73d9fd94745b851c8bc6504610 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x0f082a7870908f8cebbb2cd27a42a9225c19f898 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x69d667281778db0c3bc8177efea3a91ee95c3068 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x30d61bb28a6789f9f49d8c7fb198d63b6aba4b61 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x090f3fd9110621df127c3f9be5c6f58c02f2d5eb - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xd56f086e7b796b313d49f2bc926fac4bdd2a2b0b - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x7eb847a214192aab8fa1b503f4d4c9ddd2a08db6 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x81b3bc0ef974c16d71b8614adb8c22ccc045da01 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xc9b44ca4159dbaf5722a3dc8618e9d4b5f39d5b2 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xbeef35a63fc62a3334630d9d3b4db27093d95317 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x3d5d143381916280ff91407febeb52f2b60f33cf - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x68c9325cc268df8b9ed4a06429587f28471b5f84 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xa00cc1fb7ac185222294777c6b23a13c013f07ce - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x77021e63bcbd3c5296b0cdd8a3c3770fb0ea8fa2 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xcc28456d4ff980cee3457ca809a257e52cd9cdb0 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xec0b7e8e44c9d60efd67a89dba1d4a6e02a7a4a0 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x0c8fed5dd65542ca5f0add1acab14c2e470c9110 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xd56da2b74ba826f19015e6b7dd9dae1903e85da1 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x5482c2b11951bbb92b87858242e17abde802b398 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xd95bae63641d822dc591bd4aca7a64e53eac76f9 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x06959273e9a65433de71f5a452d529544e07ddd0 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x24bf2ee2e09477082d1ddf2f0603baa460b3f5f3 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x56d8f846415e08c5e663d89505e79f522d33f947 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x548e923281f372d28a40287d3a2d30dce482fc66 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x9d744d3d905897608d24c1b8c1c7db0d30c36cd4 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xddd23787a6b80a794d952f5fb036d0b31a8e6aff - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xa86aca6d7c393c06dcdc30473ea3d1b05c358dff - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x1ffec7119e315b15852557f654ae0052f76e6ae1 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x0f027d40c80d8f70f77d3884776531f80b21d20e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x69c66beafb06674db41b22cfc50c34a93b8d82a2 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xeedff72a683058f8ff531e8c98575f920430fdc5 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x811cfb75567a252bea23474e2ccd1286927bfe0a - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x2caccf71bdf8fff97c06a46eca29b611b1a74b5e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0xf07a84f0732dfe8eea0d3961bcd8f62c761ff508 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/base/0x8c1c499b1796d7f3c2521ac37186b52de024e58c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0xab46d39cb398fb3649ecba781180016fef75f50b - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x25048028ad87484b7fce99bc4e22dcb6c3307470 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0xdb2177fee5b0ebdc7b8038cb70f3964bb6d14143 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x42d749f736051d8933b118324cded52d1f92bec1 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0xb1a1b707b143b911c36e1a0f4f901c5017791aca - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x3319a81a316abd4c086f7048904e31ff86648b38 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x4a978a2d4fb7393063babfb0cee741b8bcd4dd4b - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0xea403e36fb592fdfdc342c38e94284ddbb0d2105 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0xe3fb01794d6912f0773171e32e723471ee8df061 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x916d7f23ccbb1d10118dcfc6ad5a10b6446ff73e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0xddd23787a6b80a794d952f5fb036d0b31a8e6aff - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0xa86aca6d7c393c06dcdc30473ea3d1b05c358dff - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x1ffec7119e315b15852557f654ae0052f76e6ae1 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x0f027d40c80d8f70f77d3884776531f80b21d20e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x69c66beafb06674db41b22cfc50c34a93b8d82a2 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0xeedff72a683058f8ff531e8c98575f920430fdc5 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x811cfb75567a252bea23474e2ccd1286927bfe0a - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x2caccf71bdf8fff97c06a46eca29b611b1a74b5e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0xf07a84f0732dfe8eea0d3961bcd8f62c761ff508 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/bnb/0x8c1c499b1796d7f3c2521ac37186b52de024e58c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x6cde5f5a192fbf3fd84df983aa6dc30dbd9f8fac - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0xd80d28850bebe6208433c298334392bc940b4fc7 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x7f7c4335ccac291ddedcef4429a626c442b627ed - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x628cb3a5a206956423d158009612813b64b19dab - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x116361f4f45e310347b43cd098fdfa459760ea7f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x5dc631ad6c26bea1a59fbf2c2680cf3df43d249f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x1a810e0b6c2dd5629afa2f0c898b9512c6f78846 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0xac1cb6d3d419da9ead0b53e62d6fb4bb53473523 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x0115d04a88990889471a88e85817aac9e961c07b - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0xd3409b7f3f54bb097433d0f4cd31c48ac33e569b - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x493bfc1adb2e60805693197f23132350ffd2a04e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0xcf4f103759770c21f945413781ca787620316988 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0xb135ebde27d366b0d62e579bae4118cb991b820e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0xecbc2f008c20729b9239317408367377c5473812 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x96e0c440d3377c2dfe4f2a82add0b045e46cbe64 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x6f5304c22ac77e228e8af4732ac6677c46e09030 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0xcb037f27eb3952222810966e28e0ceb650c65cd9 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0xddd23787a6b80a794d952f5fb036d0b31a8e6aff - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0xa86aca6d7c393c06dcdc30473ea3d1b05c358dff - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x1ffec7119e315b15852557f654ae0052f76e6ae1 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x0f027d40c80d8f70f77d3884776531f80b21d20e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x69c66beafb06674db41b22cfc50c34a93b8d82a2 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0xeedff72a683058f8ff531e8c98575f920430fdc5 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x811cfb75567a252bea23474e2ccd1286927bfe0a - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x2caccf71bdf8fff97c06a46eca29b611b1a74b5e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0xf07a84f0732dfe8eea0d3961bcd8f62c761ff508 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/pools/celo/0x8c1c499b1796d7f3c2521ac37186b52de024e58c - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/ethereum/0x7baece5d47f1bc5e1953fbe0e9931d54dab6d810 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/ethereum/0x83abecf7204d5afc1bea5df734f085f2535a9976 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/ethereum/0x4eefe02fce5b53ca33c7717bbd8ad3c9cb0609f1 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/ethereum/0xaf996125e98b5804c00ffdb4f7ff386307c99a00 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/ethereum/0x7924a818013f39cf800f5589ff1f1f0def54f31f - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/arbitrum/0xb2eb5849e2606f99fc492e9add0103c667f806d3 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/arbitrum/0x53c6ca2597711ca7a73b6921faf4031eedf71339 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/arbitrum/0x4eefe02fce5b53ca33c7717bbd8ad3c9cb0609f1 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/arbitrum/0xaf996125e98b5804c00ffdb4f7ff386307c99a00 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/arbitrum/0x7924a818013f39cf800f5589ff1f1f0def54f31f - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/optimism/0xd35937ecd47b04a1474f8569f457fc5ac395921a - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/optimism/0x4eefe02fce5b53ca33c7717bbd8ad3c9cb0609f1 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/optimism/0xaf996125e98b5804c00ffdb4f7ff386307c99a00 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/optimism/0x7924a818013f39cf800f5589ff1f1f0def54f31f - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/polygon/0x6b75f2189f0e11c52e814e09e280eb1a9a8a094a - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/polygon/0xb372b5abdb7c2ab8ad9e614be9835a42d0009153 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/polygon/0xf369277650ad6654f25412ea8bfbd5942733babc - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/polygon/0x4eefe02fce5b53ca33c7717bbd8ad3c9cb0609f1 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/polygon/0xaf996125e98b5804c00ffdb4f7ff386307c99a00 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/polygon/0x7924a818013f39cf800f5589ff1f1f0def54f31f - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/base/0x4898cf312fbff8814cab80a8d7f6ee5ad0dc73fb - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/base/0x5e78afc6c804d4382bede3a0712d210e657e9b4f - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/base/0x86b211ca7915a0c8d4659dd98242d9e801d88ab4 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/base/0xb637f7c82fd774c280e23cebc725e7cd807c66d0 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/base/0xd249c43faabc58d6dd4b0a4de598b5a956c5d8d7 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/base/0x1fbae785ce68b79f7ed4f7b27c3af3ef0e0bc3d4 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/base/0x3c1376fb8487da57d4ffb263d9d01b578c7b586b - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/base/0x7b24bed19856f4bb1d4c0421cfb328026cd936bd - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/base/0x7cf887a863d81e6a483ee947dee05cb51914923c - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/base/0x588c8cf031809486f015908864ee8699b44017e4 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/base/0x3987d38a4ff8520a8ef6bcc6f98d6da8bcd69b89 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/base/0x4eefe02fce5b53ca33c7717bbd8ad3c9cb0609f1 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/base/0xaf996125e98b5804c00ffdb4f7ff386307c99a00 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/base/0x7924a818013f39cf800f5589ff1f1f0def54f31f - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/bnb/0xde67d05242b18af00b28678db34feec883cc9cd6 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/bnb/0x4eefe02fce5b53ca33c7717bbd8ad3c9cb0609f1 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/bnb/0xaf996125e98b5804c00ffdb4f7ff386307c99a00 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/bnb/0x7924a818013f39cf800f5589ff1f1f0def54f31f - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/celo/0x4a5a8b0108f446df7c1c8a459fcfb54e844b7343 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/celo/0xf6ba006abf768ab2d1b5bba2d22d9f13eb1269d4 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/celo/0x4eefe02fce5b53ca33c7717bbd8ad3c9cb0609f1 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/celo/0xaf996125e98b5804c00ffdb4f7ff386307c99a00 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/celo/0x7924a818013f39cf800f5589ff1f1f0def54f31f - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/ethereum/0x92c2fc5f306405eab0ff0958f6d85d7f8892cf4d - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/ethereum/0xcbe856765eeec3fdc505ddebf9dc612da995e593 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/arbitrum/0x92c2fc5f306405eab0ff0958f6d85d7f8892cf4d - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/arbitrum/0xcbe856765eeec3fdc505ddebf9dc612da995e593 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/optimism/0xc1738d90e2e26c35784a0d3e3d8a9f795074bca4 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/optimism/0x92c2fc5f306405eab0ff0958f6d85d7f8892cf4d - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/optimism/0xcbe856765eeec3fdc505ddebf9dc612da995e593 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/polygon/0xda908c0bf14ad0b61ea5ebe671ac59b2ce091cbf - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/polygon/0x254aa3a898071d6a2da0db11da73b02b4646078f - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/polygon/0x92c2fc5f306405eab0ff0958f6d85d7f8892cf4d - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/polygon/0xcbe856765eeec3fdc505ddebf9dc612da995e593 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/base/0x41824081f2e7beb83048bf52465ddd7c8e471da2 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/base/0xa0c2ce1723b3939f47ad01a293292f2f75dc629d - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/base/0xc42442f6402b68626e791a447d87b35cb1c6236e - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/base/0x84537db6f6aaa2afdb71f325d14b9f5f7825bef1 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/base/0x13933689ed2c6c66e83aed64336df14896efb7e2 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/base/0x92c2fc5f306405eab0ff0958f6d85d7f8892cf4d - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/base/0xcbe856765eeec3fdc505ddebf9dc612da995e593 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/bnb/0x039df62583ddc1c5fda75db152b87113d863b6d6 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/bnb/0x92c2fc5f306405eab0ff0958f6d85d7f8892cf4d - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/bnb/0xcbe856765eeec3fdc505ddebf9dc612da995e593 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/celo/0x92c2fc5f306405eab0ff0958f6d85d7f8892cf4d - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/celo/0xcbe856765eeec3fdc505ddebf9dc612da995e593 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/ethereum/0xc39e83fe4e412a885c0577c08eb53bdb6548004a - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/ethereum/0xdbac78be00503d10ae0074e5e5873a61fc56647c - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/ethereum/0xc1cd3d0913f4633b43fcddbcd7342bc9b71c676f - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/ethereum/0x6c4c7f46d9d4ef6bc5c9e155f011ad19fc4ef321 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/ethereum/0xb2c86ff752f18499b70e8f642b3421405d50d6e9 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/ethereum/0x16588709ca8f7b84829b43cc1c5cb7e84a321b16 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/ethereum/0xd0a4c8a1a14530c7c9efdad0ba37e8cf4204d230 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/ethereum/0xf92f2e3fca01491baba0975264362cc38b1cab7b - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/ethereum/0x3e6e23198679419cd73bb6376518dcc5168c8260 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/ethereum/0x531b6a4b3f962208ea8ed5268c642c84bb29be0b - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/ethereum/0x553e9c493678d8606d6a5ba284643db2110df823 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/ethereum/0xe3170d65018882a336743a9c396c52ea4b9c5563 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/ethereum/0x1385fc1fe0418ea0b4fcf7adc61fc7535ab7f80d - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/ethereum/0x5cd0ad98ba6288ed7819246a1ebc0386c32c314b - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/ethereum/0xe945683b3462d2603a18bdfbb19261c6a4f03ad1 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/ethereum/0xa1bf0e900fb272089c9fd299ea14bfccb1d1c2c0 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/ethereum/0xe46935ae80e05cdebd4a4008b6ccaa36d2845370 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/ethereum/0x3041cbd36888becc7bbcbc0045e3b1f144466f5f - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/arbitrum/0x0ad1e922e764df5ab6d636f5d21ecc2e41e827f0 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/arbitrum/0xe945683b3462d2603a18bdfbb19261c6a4f03ad1 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/arbitrum/0xa1bf0e900fb272089c9fd299ea14bfccb1d1c2c0 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/arbitrum/0xe46935ae80e05cdebd4a4008b6ccaa36d2845370 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/arbitrum/0x3041cbd36888becc7bbcbc0045e3b1f144466f5f - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/optimism/0x6b3a3d6ed64faf933a7a4b1bd44b2efba47614ac - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/optimism/0x4ce4a1a593ea9f2e6b2c05016a00a2d300c9ffd8 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/optimism/0x0843e0f56b9e7fdc4fb95fabba22a01ef4088f41 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/optimism/0x8323d063b1d12acce4742f1e3ed9bc46d71f4222 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/optimism/0xe945683b3462d2603a18bdfbb19261c6a4f03ad1 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/optimism/0xa1bf0e900fb272089c9fd299ea14bfccb1d1c2c0 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/optimism/0xe46935ae80e05cdebd4a4008b6ccaa36d2845370 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/optimism/0x3041cbd36888becc7bbcbc0045e3b1f144466f5f - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/polygon/0xe30e4dfdbb10949c27501922f845e20cfa579f09 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/polygon/0x7e02ae3f794ebade542c92973eb1c46d7e2e935d - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/polygon/0xfa22d298e3b0bc1752e5ef2849cec1149d596674 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/polygon/0x8066ee17156e4184d69277e26fa8cbca3a845edf - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/polygon/0x418de8e0ab58abfe916a47821a055c59b9502deb - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/polygon/0xfb9caae5a5c0ab91f68542124c05d1efbb97d151 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/polygon/0xb68606a75b117906e06caa0755896ad2b3dd0272 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/polygon/0x6e33c0f5e16b45114679eac217e0c0138cefcd2e - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/polygon/0xd64fb39a5681908ad488b487d65f5d8479cb235c - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/polygon/0xe945683b3462d2603a18bdfbb19261c6a4f03ad1 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/polygon/0xa1bf0e900fb272089c9fd299ea14bfccb1d1c2c0 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/polygon/0xe46935ae80e05cdebd4a4008b6ccaa36d2845370 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/polygon/0x3041cbd36888becc7bbcbc0045e3b1f144466f5f - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/base/0x0217fc17c642d29b890bcf888e21be2378493e01 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/base/0x099d23a43da5a8a9282266dbefeaaef958150300 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/base/0xd92e0767473d1e3ff11ac036f2b1db90ad0ae55f - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/base/0xe945683b3462d2603a18bdfbb19261c6a4f03ad1 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/base/0xa1bf0e900fb272089c9fd299ea14bfccb1d1c2c0 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/base/0xe46935ae80e05cdebd4a4008b6ccaa36d2845370 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/base/0x3041cbd36888becc7bbcbc0045e3b1f144466f5f - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/bnb/0x40c547e7fd88f60d94788953b83d9342d8d133c6 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/bnb/0x397433498c7befde4b4049b98a7ff081a2c17387 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/bnb/0xf9be03505869d719ba194757943575ed2af001f2 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/bnb/0x18c40bb9281a07627ff25cea45b7511f68fd0076 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/bnb/0x270d89e983d9821a418bf193684736414fab78c5 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/bnb/0xb125aa15ad943d96e813e4a06d0c34716f897e26 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/bnb/0x813c0decbb1097fff46d0ed6a39fb5f6a83043f4 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/bnb/0x9a7ac628ba9f330341486380af729c8975388959 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/bnb/0xf2c9339945bff71dd0bffd3c142164112cd05dc6 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/bnb/0x12a4619c0bd9710732fbc458e9baa73df6c3d35f - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/bnb/0x96530dac7817f186390b64ba63d13becd079b28d - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/bnb/0x18fc1e95adb68b556212ebbad777f3fbb644db98 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/bnb/0xabbeb324b090550ca6d15ec71019915813f54f90 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/bnb/0x86d708404d0db1d97843e66d4ed6b86d11be705b - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/bnb/0xbfbba3de6a260c8374f8299c38898312c2d6e9a6 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/bnb/0xe945683b3462d2603a18bdfbb19261c6a4f03ad1 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/bnb/0xa1bf0e900fb272089c9fd299ea14bfccb1d1c2c0 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/bnb/0xe46935ae80e05cdebd4a4008b6ccaa36d2845370 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/bnb/0x3041cbd36888becc7bbcbc0045e3b1f144466f5f - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/celo/0xe945683b3462d2603a18bdfbb19261c6a4f03ad1 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/celo/0xa1bf0e900fb272089c9fd299ea14bfccb1d1c2c0 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/celo/0xe46935ae80e05cdebd4a4008b6ccaa36d2845370 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/celo/0x3041cbd36888becc7bbcbc0045e3b1f144466f5f - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/ethereum/0xd31d41dffa3589bb0c0183e46a1eed983a5e5978 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/pools/ethereum/0x391e8501b626c623d39474afca6f9e46c2686649 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/pools/ethereum/0xd0fc8ba7e267f2bc56044a7715a489d851dc6d78 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/pools/arbitrum/0x4fd47e5102dfbf95541f64ed6fe13d4ed26d2546 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/pools/optimism/0xe9033c0011f35547fa90d3f8a6ad4b666a590759 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/pools/optimism/0x0c3561d3b72e17378d99684414aa8669daeb8bd0 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/pools/polygon/0x14653ce9f406ba7f35a7ffa43c81fa7ecd99c788 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/pools/base/0x3204e9734a56a4d7c6f4f5822e14182d9d1a43c4 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/pools/base/0x43faefd4c0c25e969ac211cd97a4a51e52c729b7 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/pools/base/0xa652ab3be697c7a01fbdce4d73f8e8acd990251c - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/pools/base/0x29962083891241aad61ad97bae46d032c9c0c55c - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/pools/base/0x26bf3601b77be9c31b13b22ebca02914db9c7468 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/pools/base/0x0d2edd335982f56662d772b93d86901eb9bd2ff9 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/pools/base/0xbaed273edd493930711fe88690ebd1f30f7f55ab - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/pools/bnb/0x16033643947bf4d8a1ae37b055edf57cb183106a - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/pools/celo/0xf59abf32c1e8c5d2c6e3faa2131533bbcd466194 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/pools/celo/0x0312187403bf72b8d2d80729894d6ac3300bd63f - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/pools/base/0x416fdbc4fb8d4d1f48d0d3778c59dfa5352e9b15 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/ethereum/0x90908e414d3525e33733d320798b5681508255ea - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/pools/arbitrum/0x5918aca9ae924e6eaaa3d293bb92bdec9ab79338 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/pools/arbitrum/0x8270e64d22cf13e92c641c4006408c7d7e3ff341 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/pools/arbitrum/0x16503510c58da73486950b72a12ead3d1d8355dd - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/pools/arbitrum/0x90908e414d3525e33733d320798b5681508255ea - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/pools/optimism/0x7505159f644ddc5eae21c119e328d0d5bee574b0 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/pools/optimism/0xe870bfe4aacb6e234b645e535d26c53790d50e78 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/pools/optimism/0x2e2d190ad4e0d7be9569baebd4d33298379b0502 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/pools/optimism/0x90908e414d3525e33733d320798b5681508255ea - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/pools/polygon/0xb834093d7e46f7644be45e77281394d31003e866 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/pools/polygon/0xb5a1fd804342cfb679bd8ada75718bc3ec43097e - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/pools/polygon/0x90908e414d3525e33733d320798b5681508255ea - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/pools/base/0x9e71e2b14d7e6d30811628ab0965f28e4e2edbce - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/pools/base/0xa011da4a0c9261ecf4694bf73a74d113aa261133 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/pools/base/0x7ab922c1bfdf7df977c7531c5782074d866f3adc - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/pools/base/0xe2d2050430e341a8f3988e2726e44d9370f8cd3a - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/pools/base/0xed66ba3ea44425805a085b1ca80d00467b055b38 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/pools/base/0x40dade19adc198125ec237a2c48b3408568b2f81 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/pools/base/0x166bc40da621d3cb978e24334f844b84ddef25f8 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/pools/base/0x76bf0abd20f1e0155ce40a62615a90a709a6c3d8 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/pools/base/0x90908e414d3525e33733d320798b5681508255ea - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/pools/bnb/0x6948d6c8532c6b0006cb67c6fb9c399792c8ac91 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/pools/bnb/0x90908e414d3525e33733d320798b5681508255ea - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/pools/celo/0x4e40cf4a7d8724e5adc2b791bbf9451d1e260b93 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/pools/celo/0x90908e414d3525e33733d320798b5681508255ea - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/pools/ethereum/0xc0067d751fb1172dbab1fa003efe214ee8f419b6 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/ethereum/0xc3d7aa944105d3fafe07fc1822102449c916a8d0 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/arbitrum/0xd6b4cce96ddf8aab2e5750983af9a901f17fbc36 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/arbitrum/0x4cef551255ec96d89fec975446301b5c4e164c59 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/arbitrum/0xc0067d751fb1172dbab1fa003efe214ee8f419b6 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/arbitrum/0xc3d7aa944105d3fafe07fc1822102449c916a8d0 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/optimism/0xdd0c6bae8ad5998c358b823df15a2a4181da1b80 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/optimism/0xc0067d751fb1172dbab1fa003efe214ee8f419b6 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/optimism/0xc3d7aa944105d3fafe07fc1822102449c916a8d0 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/polygon/0xc0067d751fb1172dbab1fa003efe214ee8f419b6 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/polygon/0xc3d7aa944105d3fafe07fc1822102449c916a8d0 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/base/0x5e6ff2fa4ca244b6b33c7286d368120822eacc11 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/base/0x98efd62b4bfbde6393b18b063c506ce5a77f4810 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/base/0x3c5096df639262db0a6cd0172f08709d4161094b - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/base/0xae31f0e673fc5f33cfc0e9abb426d8051404a7c5 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/base/0xc0067d751fb1172dbab1fa003efe214ee8f419b6 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/base/0xc3d7aa944105d3fafe07fc1822102449c916a8d0 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/bnb/0xc0067d751fb1172dbab1fa003efe214ee8f419b6 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/bnb/0xc3d7aa944105d3fafe07fc1822102449c916a8d0 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/celo/0xd10456ce05b9af05c8eede0f93ea8aa80a0daa2f - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/celo/0x065c22a16f6531706681fabbc8df135fe6eb1c2e - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/celo/0x8ab8d851c6b31d8a4d42fd7d3e47b20861b025f2 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/celo/0xc0067d751fb1172dbab1fa003efe214ee8f419b6 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/celo/0xc3d7aa944105d3fafe07fc1822102449c916a8d0 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/ethereum/0x2982d3295a0e1a99e6e88ece0e93ffdfc5c761ae - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/ethereum/0xc593fe9193b745447e86b45ea0bf62565ee030cc - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/ethereum/0x88051b0eea095007d3bef21ab287be961f3d8598 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/ethereum/0xaf21b0ec0197e63a5c6cc30c8e947eb8165c6212 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/ethereum/0x9c84f58bb51fabd18698efe95f5bab4f33e96e8f - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/arbitrum/0xb31273fd2dfc05e6fd91a3b8a2a681aeb0fbcf48 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/arbitrum/0xaf7b48ae2f4773fd44f9208cca3db5ae7bfa7e37 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/arbitrum/0xc2125a452115ff5a300cc2a6ffae99637f6e329d - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/arbitrum/0xb08a8794a5d3ccca3725d92964696858d3201909 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/arbitrum/0xaf21b0ec0197e63a5c6cc30c8e947eb8165c6212 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/arbitrum/0x9c84f58bb51fabd18698efe95f5bab4f33e96e8f - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/optimism/0xae99efe6b04bbe5b8b4ad567946fb84b35681abb - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/optimism/0xaf21b0ec0197e63a5c6cc30c8e947eb8165c6212 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/optimism/0x9c84f58bb51fabd18698efe95f5bab4f33e96e8f - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/polygon/0xaf21b0ec0197e63a5c6cc30c8e947eb8165c6212 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/polygon/0x9c84f58bb51fabd18698efe95f5bab4f33e96e8f - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/base/0x6696710b8e3dc0d844c8b9244767962a4a61ad97 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/base/0xcde77ef185a8f886d03b109573cc1dcdcf3cf1f8 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/base/0xaf21b0ec0197e63a5c6cc30c8e947eb8165c6212 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/base/0x9c84f58bb51fabd18698efe95f5bab4f33e96e8f - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/bnb/0x35f5387decce5a234da1a32ca3c9e338a48bcf37 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/bnb/0x4178dd7eb2eb983ba7f7e41648cf91db6be20190 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/bnb/0xaf21b0ec0197e63a5c6cc30c8e947eb8165c6212 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/bnb/0x9c84f58bb51fabd18698efe95f5bab4f33e96e8f - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/celo/0xb6c8f9490314394cfc6edacb8717bfdc1eb8dab5 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/celo/0x1625fe58cdb3726e5841fb2bb367dde9aaa009b3 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/celo/0xb1ed164c736909ba7ddbc1feb7ced4eaad854a87 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/celo/0x95faa9a91cd6c1c018e4b1a6fc4c89d4f1695e5d - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/celo/0xa143ccf73c25eec6f38bd1b741043ebea228b8e9 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/celo/0x2e067e0eab7fd31c01473c0f56f3295afb82e461 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/celo/0xbc83c60e853398d263c1d88899cf5a8b408f9654 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/celo/0xaf21b0ec0197e63a5c6cc30c8e947eb8165c6212 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/celo/0x9c84f58bb51fabd18698efe95f5bab4f33e96e8f - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/pools/ethereum/0x202a6012894ae5c288ea824cbc8a9bfb26a49b93 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/pools/ethereum/0x744159757cac173a7a3ecf5e97adb10d1a725377 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/pools/ethereum/0x127452f3f9cdc0389b0bf59ce6131aa3bd763598 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/pools/ethereum/0x5ced44f03ff443bbe14d8ea23bc24425fb89e3ed - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/pools/arbitrum/0x5ced44f03ff443bbe14d8ea23bc24425fb89e3ed - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/pools/optimism/0x2264ba9dc0b257c69eeae7782e8ff608cc65d6a7 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/pools/optimism/0x5ced44f03ff443bbe14d8ea23bc24425fb89e3ed - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/pools/polygon/0x00a59c2d0f0f4837028d47a391decbffc1e10608 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/pools/polygon/0x5ced44f03ff443bbe14d8ea23bc24425fb89e3ed - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/pools/base/0xad6e8f6a34087bddfb03815e2c10e4f7bfd4395b - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/pools/base/0xd5bb156cb73bfca62f68dc3dff7e5ec4e305b861 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/pools/base/0xc0d8f259578c985947a050802fb4857261af0bf3 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/pools/base/0x5ced44f03ff443bbe14d8ea23bc24425fb89e3ed - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/pools/bnb/0x74f7a360eb36a46b675ea932ea07094a3ace441f - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/pools/bnb/0x626761cc5b9fafe4696bf8def4aa015576bb4bef - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/pools/bnb/0x5ced44f03ff443bbe14d8ea23bc24425fb89e3ed - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/pools/celo/0xc767c0b2e2e56c455fd29f9ee9b6e6f035c71ed4 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/pools/celo/0x625cb959213d18a9853973c2220df7287f1e5b7d - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/pools/celo/0x5ced44f03ff443bbe14d8ea23bc24425fb89e3ed - 2024-07-10T19:43:34.135Z + 2024-05-20T17:20:52.753Z 0.8 \ No newline at end of file diff --git a/apps/web/public/tokens-sitemap.xml b/apps/web/public/tokens-sitemap.xml index 24e9f6d35e0..e727529afef 100644 --- a/apps/web/public/tokens-sitemap.xml +++ b/apps/web/public/tokens-sitemap.xml @@ -2,5187 +2,3182 @@ https://app.uniswap.org/explore/tokens/ethereum/0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0xdac17f958d2ee523a2206206994597c13d831ec7 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x2260fac5e5542a773aa44fbcfedf7c193bc2c599 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x6982508145454ce325ddbe47a25d4ec3d2311933 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x7f39c581f595b53c5cb19bd0b3f8da6c935e2ca0 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x6b175474e89094c44da98b954eedeac495271d0f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x6123b0049f904d730db3c36a31167d9d4121fa6b - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x1f9840a85d5af5bf1d1762f925bdaddc4201f984 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0xcf0c122c6b73ff809c693db761e7baebe62b6a2e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0xfaba6f8e4a5e8ab82f62fe7c39859fa577269be3 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x58cb30368ceb2d194740b144eab4c2da8a917dcb - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x4c9edd5852cd905f086c759e8383e09bff1e68b3 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0xaaee1a9723aadb7afa2810263653a34ba2c21c7a - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x514910771af9ca656af840dff83e8264ecf986ca - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x95ad61b0a150d79219dcf64e1e6cc01f0b64c4ce - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x5b7533812759b45c2b44c19e320ba2cd2681b542 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0xae78736cd615f374d3085123a210448e74fc6393 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0xb9f599ce614feb2e1bbe58f180f370d05b39344e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0xd5f7838f5c461feff7fe49ea5ebaf7728bb0adfa - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0xd31a59c85ae9d8edefec411d448f90841571b89c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x6a7eff1e2c355ad6eb91bebb5ded49257f3fed98 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x576e2bed8f7b46d34016198911cdf9886f78bea7 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x1258d60b224c0c5cd888d37bbf31aa5fcfb7e870 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x62d0a8458ed7719fdaf978fe5929c6d342b0bfce - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x9f8f72aa9304c8b593d555f12ef6589cc3a579a2 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x77e06c9eccf2e797fd462a92b6d7642ef85b0a44 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x24fcfc492c1393274b6bcd568ac9e225bec93584 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x27702a26126e0b3702af63ee09ac4d1a084ef628 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0xd46ba6d942050d489dbd938a2c909a5d5039a161 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0xbe9895146f7af43049ca1c1ae358b0541ea49704 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x72f713d11480dcf08b37e1898670e736688d218d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x0001a500a6b18995b03f44bb040a5ffc28e45cb0 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x9e9fbde7c7a83c43913bddc8779158f1368f0413 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x5f98805a4e8be255a32880fdec7f6728c6568ba0 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x2b591e99afe9f32eaa6214f7b7629768c40eeb39 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x1ae7e1d0ce06364ced9ad58225a1705b3e5db92b - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x046eee2cc3188071c02bfc1745a6b17c656e3f3d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x84018071282d4b2996272659d9c01cb08dd7327f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x12970e6868f88f6557b76120662c1b3e50a646bf - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0xaea46a60368a7bd060eec7df8cba43b7ef41ad85 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x6de037ef9ad2725eb40118bb1702ebb27e4aeb24 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0xc01154b4ccb518232d6bbfc9b9e6c5068b766f82 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x5a98fcbea516cf06857215779fd812ca3bef1b32 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x102c776ddb30c754ded4fdcc77a19230a60d4e4f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x72e4f9f808c49a2a61de9c5896298920dc4eeea9 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x467719ad09025fcc6cf6f8311755809d45a5e5f3 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0xf19308f923582a6f7c465e5ce7a9dc1bec6665b1 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x710287d1d39dcf62094a83ebb3e736e79400068a - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0xf951e335afb289353dc249e82926178eac7ded78 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0xf017d3690346eb8234b85f74cee5e15821fee1f4 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x8c282c35b5e1088bb208991c151182a782637699 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0xeaa63125dd63f10874f99cdbbb18410e7fc79dd3 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0xde342a3e269056fc3305f9e315f4c40d917ba521 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x2dff88a56767223a5529ea5960da7a3f5f766406 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x626e8036deb333b408be468f951bdb42433cbf18 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0xdd66781d0e9a08d4fbb5ec7bac80b691be27f21d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0xb23d80f5fefcddaa212212f028021b41ded428cf - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0xbaac2b4491727d78d2b78815144570b9f2fe8899 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0xf8ebf4849f1fa4faf0dff2106a173d3a6cb2eb3a - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0xb90b2a35c65dbc466b04240097ca756ad2005295 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x1614f18fc94f47967a3fbe5ffcd46d4e7da3d787 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0xf1df7305e4bab3885cab5b1e4dfc338452a67891 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x91fbb2503ac69702061f1ac6885759fc853e6eae - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0xa9e8acf069c58aec8825542845fd754e41a9489a - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x2c95d751da37a5c1d9c5a7fd465c1d50f3d96160 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0xe453c3409f8ad2b1fe1ed08e189634d359705a5b - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x89d584a1edb3a70b3b07963f9a3ea5399e38b136 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x4507cef57c46789ef8d1a19ea45f4216bae2b528 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0xd1d2eb1b1e90b638588728b4130137d262c87cae - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0xe92344b4edf545f3209094b192e46600a19e7c2d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x8a0a9b663693a22235b896f70a229c4a22597623 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x1bbe973bef3a977fc51cbed703e8ffdefe001fed - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0xa41d2f8ee4f47d3b860a149765a7df8c3287b7f0 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x761d38e5ddf6ccf6cf7c55759d5210750b5d60f3 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0xc18360217d8f7ab5e7c516566761ea12ce7f9d72 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0xe28b3b32b6c345a34ff64674606124dd5aceca30 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x168e209d7b2f58f1f24b8ae7b7d35e662bbf11cc - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0xb131f4a55907b10d1f0a50d8ab8fa09ec342cd74 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x3472a5a71965499acd81997a54bba8d852c6e53d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x7dd9c5cba05e151c895fde1cf355c9a1d5da6429 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x19efa7d0fc88ffe461d1091f8cbe56dc2708a84f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x14fee680690900ba0cccfc76ad70fd1b95d10e16 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x3c3a81e81dc49a522a592e7622a7e711c06bf354 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0xa1290d69c65a6fe4df752f95823fae25cb99e5a7 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x92f419fb7a750aed295b0ddf536276bf5a40124f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x2c06ba9e7f0daccbc1f6a33ea67e85bb68fbee3a - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x3d658390460295fb963f54dc0899cfb1c30776df - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x8e870d67f660d95d5be530380d0ec0bd388289e1 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x853d955acef822db058eb8505911ed77f175b99e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x1294f4183763743c7c9519bec51773fb3acd78fd - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x4e15361fd6b4bb609fa63c81a2be19d873717870 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x695d38eb4e57e0f137e36df7c1f0f2635981246b - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x40a7df3df8b56147b781353d379cb960120211d7 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0xaaef88cea01475125522e117bfe45cf32044e238 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x163f8c2467924be0ae7b5347228cabf260318753 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x30672ae2680c319ec1028b69670a4a786baa0f35 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0xc944e90c64b2c07662a292be6244bdf05cda44a7 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x15e6e0d4ebeac120f9a97e71faa6a0235b85ed12 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x7d225c4cc612e61d26523b099b0718d03152edef - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x82af49447d8a07e3bd95bd0d56f35241523fbab1 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0xaf88d065e77c8cc2239327c5edb3a432268e5831 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0xff970a61a04b1ca14834a43f5de4533ebddb5cc8 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x912ce59144191c1204e64559fe8253a0e49e6548 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x2f2a2543b76a4166549f7aab2e75bef0aefc5b0f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x5979d7b546e38e414f7e9822514be443a4800529 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x35751007a407ca6feffe80b3cb397736d2cf4dbe - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0xda10009cbd5d07dd0cecc66161fc93d7c9000da1 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0xeb466342c4d449bc9f53a865d5cb90586f405215 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0xfc5a1a6eb076a2c7ad06ed22c90d7e710e35ad0a - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x0c880f6761f1af8d9aa9c466984b80dab9a8c9e8 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0xf97f4df75117a78c1a5a0dbb814af92458539fb4 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x9623063377ad1b27544c965ccd7342f7ea7e88c7 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x539bde0d7dbd336b79148aa742883198bbf60342 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x3082cc23568ea640225c2467653db90e9250aaa0 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x18c11fd286c5ec11c3b683caa813b77f5163a122 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x289ba1701c2f088cf0faf8b3705246331cb8a839 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x4cb9a7ae498cedcbb5eae9f25736ae7d428c9d66 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x00cbcf7b3d37844e44b888bc747bdd75fcf4e555 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0xfa7f8980b0f1e64a2062791cc3b0871572f1f7f0 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0xd79bb960dc8a206806c3a428b31bca49934d18d7 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x3096e7bfd0878cc65be71f8899bc4cfb57187ba3 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x13ad51ed4f1b7e9dc168d8a00cb3f4ddd85efa60 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x4e352cf164e64adcbad318c3a1e222e9eba4ce42 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x11cdb42b0eb46d95f990bedd4695a6e3fa034978 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0xba5ddd1f9d7f570dc94a51479a000e3bce967196 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0xc8ccbd97b96834b976c995a67bf46e5754e2c48e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0xd07d35368e04a839dee335e213302b21ef14bb4a - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x323665443cef804a3b5206103304bd4872ea4253 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x83d6c8c06ac276465e4c92e7ac8c23740f435140 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x87aaffdf26c6885f6010219208d5b161ec7609c0 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x1b8d516e2146d7a32aca0fcbf9482db85fd42c3a - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0xafccb724e3aec1657fc9514e3e53a0e71e80622d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x4425742f1ec8d98779690b5a3a6276db85ddc01a - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0xec70dcb4a1efa46b8f2d97c310c9c4790ba5ffa8 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x3419875b4d3bca7f3fdda2db7a476a79fd31b4fe - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x3b60ff35d3f7f62d636b067dd0dc0dfdad670e4e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x58b9cb810a68a7f3e1e4f8cb45d1b9b3c79705e8 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0xfa5ed56a203466cbbc2430a43c66b9d8723528e7 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x95146881b86b3ee99e63705ec87afe29fcc044d9 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x088cd8f5ef3652623c22d48b1605dcfe860cd704 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0xbfd5206962267c7b4b4a8b3d76ac2e1b2a5c4d5e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x6daf586b7370b14163171544fca24abcc0862ac5 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x9d2f299715d94d8a7e6f5eaa8e654e8c74a988a7 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x580e933d90091b9ce380740e3a4a39c67eb85b4c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x655a6beebf2361a19549a99486ff65f709bd2646 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x9e64d3b9e8ec387a9a58ced80b71ed815f8d82b5 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x2297aebd383787a160dd0d9f71508148769342e3 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x6694340fc020c5e6b96567843da2df01b2ce1eb6 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x772598e9e62155d7fdfe65fdf01eb5a53a8465be - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x431402e8b9de9aa016c743880e04e517074d8cec - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0xd74f5255d557944cf7dd0e45ff521520002d5748 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x6fd58f5a2f3468e35feb098b5f59f04157002407 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x561877b6b3dd7651313794e5f2894b2f18be0766 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0xf9ca0ec182a94f6231df9b14bd147ef7fb9fa17c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0xd77b108d4f6cefaa0cae9506a934e825becca46e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0xd56734d7f9979dd94fae3d67c7e928234e71cd4c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0xf1264873436a0771e440e2b28072fafcc5eebd01 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x5575552988a3a80504bbaeb1311674fcfd40ad4b - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x0341c0c0ec423328621788d4854119b97f44e391 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x764bfc309090e7f93edce53e5befa374cdcb7b8e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0xaaa6c1e32c55a7bfa8066a6fae9b42650f262418 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x9e20461bc2c4c980f62f1b279d71734207a6a356 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x7fb7ede54259cb3d4e1eaf230c7e2b1ffc951e9a - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x3a18dcc9745edcd1ef33ecb93b0b6eba5671e7ca - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x000000000026839b3f4181f2cf69336af6153b99 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x8b0e6f19ee57089f7649a455d89d7bc6314d04e8 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x31c91d8fb96bff40955dd2dbc909b36e8b104dde - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x25d887ce7a35172c62febfd67a1856f20faebb00 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0xd4d42f0b6def4ce0383636770ef773390d85c61a - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0xf8388c2b6edf00e2e27eef5200b1befb24ce141d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x619c82392cb6e41778b7d088860fea8447941f4c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x94025780a1ab58868d9b2dbbb775f44b32e8e6e5 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0xad4b9c1fbf4923061814dd9d5732eb703faa53d4 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0xd7a892f28dedc74e6b7b33f93be08abfc394a360 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x3269a3c00ab86c753856fd135d97b87facb0d848 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x4568ca00299819998501914690d6010ae48a59ba - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x21e60ee73f17ac0a411ae5d690f908c3ed66fe12 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0xd3188e0df68559c0b63361f6160c57ad88b239d8 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x2b41806cbf1ffb3d9e31a9ece6b738bf9d6f645f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0xf19547f9ed24aa66b03c3a552d181ae334fbb8db - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x35e6a59f786d9266c7961ea28c7b768b33959cbb - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x59a729658e9245b0cf1f8cb9fb37945d2b06ea27 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0xb56c29413af8778977093b9b4947efeea7136c36 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x43ab8f7d2a8dd4102ccea6b438f6d747b1b9f034 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x1d987200df3b744cfa9c14f713f5334cb4bc4d5d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x3404149e9ee6f17fb41db1ce593ee48fbdcd9506 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x080f6aed32fc474dd5717105dba5ea57268f46eb - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0xb5a628803ee72d82098d4bcaf29a42e63531b441 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x1622bf67e6e5747b81866fe0b85178a93c7f86e3 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x7dd747d63b094971e6638313a6a2685e80c7fb2e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0xa2f9ecf83a48b86265ff5fd36cdbaaa1f349916c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x17a8541b82bf67e10b0874284b4ae66858cb1fd5 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0xbcd4d5ac29e06e4973a1ddcd782cd035d04bc0b7 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x42069d11a2cc72388a2e06210921e839cfbd3280 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0xbbea044f9e7c0520195e49ad1e561572e7e1b948 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0xe85b662fe97e8562f4099d8a1d5a92d4b453bf30 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x3d9907f9a368ad0a51be60f7da3b97cf940982d8 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x4e51ac49bc5e2d87e0ef713e9e5ab2d71ef4f336 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/optimism/0x4200000000000000000000000000000000000006 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/optimism/0x7f5c764cbc14f9669b88837ca1490cca17c31607 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/optimism/0x4200000000000000000000000000000000000042 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/optimism/0x0b2c639c533813f4aa9d7837caf62653d097ff85 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/optimism/0x1f32b1c2345538c0c6f582fcb022739c4a194ebb - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/optimism/0x68f180fcce6836688e9084f035309e29bf0a2095 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/optimism/0x94b008aa00579c1307b0ef2c499ad98a8ce58e58 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/optimism/0xda10009cbd5d07dd0cecc66161fc93d7c9000da1 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/optimism/0xdc6ff44d5d932cbd77b52e5612ba0529dc6226f1 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/optimism/0x8c6f28f2f1a3c87f0f938b96d27520d9751ec8d9 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/optimism/0x8700daec35af8ff88c16bdf0418774cb3d7599b4 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/optimism/0x920cf626a271321c151d027030d5d08af699456b - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/optimism/0x6c84a8f1c29108f47a79964b5fe888d4f4d0de40 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/optimism/0x9e1028f5f1d5ede59748ffcee5532509976840e0 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/optimism/0xeb466342c4d449bc9f53a865d5cb90586f405215 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/optimism/0x350a791bfc2c21f9ed5d10980dad2e2638ffa7f6 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/optimism/0x17aabf6838a6303fc6e9c5a227dc1eb6d95c829a - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/optimism/0xf467c7d5a4a9c4687ffc7986ac6ad5a4c81e1404 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/optimism/0x76fb31fb4af56892a25e32cfc43de717950c9278 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/optimism/0xc5b001dc33727f8f26880b184090d3e252470d45 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/optimism/0x9560e827af36c94d2ac33a39bce1fe78631088db - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/optimism/0x9bcef72be871e61ed4fbbc7630889bee758eb81d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/optimism/0x50c5725949a6f0c72e6c4a641f24049a917db0cb - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/optimism/0xf98dcd95217e15e05d8638da4c91125e59590b07 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/optimism/0x4b03afc91295ed778320c2824bad5eb5a1d852dd - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/optimism/0xc40f949f8a4e094d1b49a23ea9241d289b7b2819 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/optimism/0x323665443cef804a3b5206103304bd4872ea4253 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/optimism/0x50bce64397c75488465253c0a034b8097fea6578 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/optimism/0x296f55f8fb28e498b858d0bcda06d955b2cb3f97 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/optimism/0x2598c30330d5771ae9f983979209486ae26de875 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/optimism/0x0994206dfe8de6ec6920ff4d779b0d950605fb53 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/optimism/0xc3248a1bd9d72fa3da6e6ba701e58cbf818354eb - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/optimism/0x6fd9d7ad17242c41f7131d257212c54a0e816691 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/optimism/0x14778860e937f509e651192a90589de711fb88a9 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/optimism/0xdfa46478f9e5ea86d57387849598dbfb2e964b02 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/optimism/0x9b88d293b7a791e40d36a39765ffd5a1b9b5c349 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/optimism/0x3eb398fec5f7327c6b15099a9681d9568ded2e82 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/optimism/0x217d47011b23bb961eb6d93ca9945b7501a5bb11 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/optimism/0xbfd5206962267c7b4b4a8b3d76ac2e1b2a5c4d5e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/optimism/0x1cef2d62af4cd26673c7416957cc4ec619a696a7 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/optimism/0x9fd22a17b4a96da3f83797d122172c450381fb88 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/optimism/0xaddb6a0412de1ba0f936dcaeb8aaa24578dcf3b2 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x2791bca1f2de4661ed88a30c99a7a9449aa84174 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x7ceb23fd6bc0add59e62ac25578270cff1b9f619 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x0d500b1d8e8ef31e21c99d1db9a6444d3adf1270 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x3c499c542cef5e3811e1192ce70d8cc03d5c3359 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x1bfd67037b42cf73acf2047067bd4f2c47d9bfd6 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0xc2132d05d31c914a87c6611c10748aeb04b58e8f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x53e0bca35ec356bd5dddfebbd1fc0fd03fabad39 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x61299774020da444af134c82fa83e3810b309991 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0xd6df932a45c0f255f85145f286ea0b292b21c90b - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x2ad2934d5bfb7912304754479dd1f096d5c807da - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0xc3c7d422809852031b44ab29eec9f1eff2a58756 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x8f3cf7ad23cd3cadbd9735aff958023239c6a063 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x750e4c4984a9e0f12978ea6742bc1c5d248f40ed - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x111111517e4929d3dcbdfa7cce55d30d4b6bc4d6 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0xd0258a3fd00f38aa8090dfee343f10a9d4d30d3f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x430ef9263e76dae63c84292c3409d61c598e9682 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0xb33eaad8d922b1083446dc23f610c2567fb5180f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0xdc3326e71d45186f113a2f448984ca0e8d201995 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x311434160d7537be358930def317afb606c0d737 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x0b3f868e0be5597d5db7feb59e1cadbb0fdda50a - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0xe3f2b1b2229c0333ad17d03f179b87500e7c5e01 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0xac0f66379a6d7801d7726d5a943356a172549adb - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0xf88332547c680f755481bf489d890426248bb275 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0xe5417af564e4bfda1c483642db72007871397896 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0xe261d618a959afffd53168cd07d12e37b26761db - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0xe0b52e49357fd4daf2c15e02058dce6bc0057db4 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0xbbba073c31bf03b8acf7c28ef0738decf3695683 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0xe238ecb42c424e877652ad82d8a939183a04c35f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x3b56a704c01d650147ade2b8cee594066b3f9421 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x5fe2b58c013d7601147dcdd68c143a77499f5531 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x172370d5cd63279efa6d502dab29171933a610af - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x53df32548214f51821cf1fe4368109ac5ddea1ff - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0xff76c0b48363a7c7307868a81548d340049b0023 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x6f8a06447ff6fcf75d803135a7de15ce88c1d4ec - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x50b728d8d964fd00c2d0aad81718b71311fef68a - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x3a58a54c066fdc0f2d55fc9c89f0415c92ebf3c4 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x03b54a6e9a984069379fae1a4fc4dbae93b3bccd - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0xd93f7e271cb87c23aaa73edc008a79646d1f9912 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x200c234721b5e549c3693ccc93cf191f90dc2af9 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x11cd37bb86f65419713f30673a480ea33c826872 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x8a16d4bf8a0a716017e8d2262c4ac32927797a2f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x9a71012b13ca4d3d0cdc72a177df3ef03b0e76a3 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0xa1c57f48f0deb89f569dfbe6e2b7f46d33606fd4 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x190eb8a183d22a4bdf278c6791b152228857c033 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x2f6f07cdcf3588944bf4c42ac74ff24bf56e7590 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x235737dbb56e8517391473f7c964db31fa6ef280 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x0b220b82f3ea3b7f6d9a1d8ab58930c064a2b5bf - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x8bff1bd27e2789fe390acabc379c380a83b68e84 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0xb58458c52b6511dc723d7d6f3be8c36d7383b4a8 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x323665443cef804a3b5206103304bd4872ea4253 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x2760e46d9bb43dafcbecaad1f64b93207f9f0ed7 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x18ec0a6e18e5bc3784fdd3a3634b31245ab704f6 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x431d5dff03120afa4bdf332c61a6e1766ef37bdb - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x6f7c932e7684666c9fd1d44527765433e01ff61d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0xeee3371b89fc43ea970e908536fcddd975135d8a - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0xe5b49820e5a1063f6f4ddf851327b5e8b2301048 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0xaa3717090cddc9b227e49d0d84a28ac0a996e6ff - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x62a872d9977db171d9e213a5dc2b782e72ca0033 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x381caf412b45dac0f62fbeec89de306d3eabe384 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0xe0bceef36f3a6efdd5eebfacd591423f8549b9d5 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x23d29d30e35c5e8d321e1dc9a8a61bfd846d4c5c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x282d8efce846a88b159800bd4130ad77443fa1a1 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x74dd45dd579cad749f9381d6227e7e02277c944b - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x714db550b574b3e927af3d93e26127d15721d4c2 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0xfa68fb4628dff1028cfec22b4162fccd0d45efb6 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0xe631dabef60c37a37d70d3b4f812871df663226f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0xdb725f82818de83e99f1dac22a9b5b51d3d04dd4 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x3c59798620e5fec0ae6df1a19c6454094572ab92 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x0d0b8488222f7f83b23e365320a4021b12ead608 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0xa380c0b01ad15c8cf6b46890bddab5f0868e87f3 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x8a953cfe442c5e8855cc6c61b1293fa648bae472 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x45c32fa6df82ead1e2ef74d17b76547eddfaff89 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x11cd72f7a4b699c67f225ca8abb20bc9f8db90c7 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x0c9c7712c83b3c70e7c5e11100d33d9401bdf9dd - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x77a6f2e9a9e44fd5d5c3f9be9e52831fc1c3c0a0 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0xbfc70507384047aa74c29cdc8c5cb88d0f7213ac - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0xfcb54da3f4193435184f3f647467e12b50754575 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x9a6a40cdf21a0af417f1b815223fd92c85636c58 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0xe111178a87a3bff0c8d18decba5798827539ae99 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x82617aa52dddf5ed9bb7b370ed777b3182a30fd1 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x2ab0e9e4ee70fff1fb9d67031e44f6410170d00e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0xa486c6bc102f409180ccb8a94ba045d39f8fc7cb - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0xc4a206a306f0db88f98a3591419bc14832536862 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0xf0059cc2b3e980065a906940fbce5f9db7ae40a7 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x16eccfdbb4ee1a85a33f3a9b21175cd7ae753db4 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x553d3d295e0f695b9228246232edf400ed3560b5 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x14af1f2f02dccb1e43402339099a05a5e363b83c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x7bdf330f423ea880ff95fc41a280fd5ecfd3d09f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x8505b9d2254a7ae468c0e9dd10ccea3a837aef5c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0xe2aa7db6da1dae97c5f5c6914d285fbfcc32a128 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0xb7b31a6bc18e48888545ce79e83e06003be70930 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x1631244689ec1fecbdd22fb5916e920dfc9b8d30 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0xf6372cdb9c1d3674e83842e3800f2a62ac9f3c66 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x692ac1e363ae34b6b489148152b12e2785a3d8d6 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x0266f4f08d82372cf0fcbccc0ff74309089c74d1 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x7fbc10850cae055b27039af31bd258430e714c62 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0xa3fa99a148fa48d14ed51d610c367c61876997f1 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x9dbfc1cbf7a1e711503a29b4b5f9130ebeccac96 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x236aa50979d5f3de3bd1eeb40e81137f22ab794b - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0xf86df9b91f002cfeb2aed0e6d05c4c4eaef7cf02 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x4200000000000000000000000000000000000006 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x6921b130d297cc43754afba22e5eac0fbf8db75b - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x5babfc2f240bc5de90eb7e19d789412db1dec402 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x532f27101965dd16442e59d40670faf5ebb142e4 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x833589fcd6edb6e08f4c7c32d4f71b54bda02913 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x4ed4e862860bed51a9570b96d89af5e1b0efefed - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0xc1cba3fcea344f92d9239c08c0568f6f2f0ee452 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0xac1bd2486aaf3b5c0fc3fd868558b082a531b2b4 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x0d97f261b1e88845184f678e2d1e7a98d9fd38de - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x8129b94753f22ec4e62e2c4d099ffe6773969ebc - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x3f14920c99beb920afa163031c4e47a3e03b3e4a - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x940181a94a35a4569e4529a3cdfb74e38fd98631 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x3419875b4d3bca7f3fdda2db7a476a79fd31b4fe - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0xa067436db77ab18b1a315095e4b816791609897c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0xafb89a09d82fbde58f18ac6437b3fc81724e4df6 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x489fe42c267fe0366b16b0c39e7aeef977e841ef - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x2ae3f1ec7f1f5012cfeab0185bfc7aa3cf0dec22 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0xdc46c1e93b71ff9209a0f8076a9951569dc35855 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x91f45aa2bde7393e0af1cc674ffe75d746b93567 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x236aa50979d5f3de3bd1eeb40e81137f22ab794b - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0xf6e932ca12afa26665dc4dde7e27be02a7c02e50 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x524d524b4c9366be706d3a90dcf70076ca037ae3 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x5b5dee44552546ecea05edea01dcd7be7aa6144a - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x2598c30330d5771ae9f983979209486ae26de875 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0xfa980ced6895ac314e7de34ef1bfae90a5add21b - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x469fda1fb46fcb4befc0d8b994b516bd28c87003 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x4e496c0256fb9d4cc7ba2fdf931bc9cbb7731660 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x27d2decb4bfc9c76f0309b8e88dec3a601fe25a8 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0xbfd5206962267c7b4b4a8b3d76ac2e1b2a5c4d5e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x9e1028f5f1d5ede59748ffcee5532509976840e0 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x3c3aa127e6ee3d2f2e432d0184dd36f2d2076b52 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0xba5e6fa2f33f3955f0cef50c63dcc84861eab663 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x97c806e7665d3afd84a8fe1837921403d59f3dcc - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x8ee73c484a26e0a5df2ee2a4960b789967dd0415 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x00e57ec29ef2ba7df07ad10573011647b2366f6d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x8f019931375454fe4ee353427eb94e2e0c9e0a8c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x93e6407554b2f02640ab806cd57bd83e848ec65d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0x55d398326f99059ff775485246999027b3197955 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0xbb4cdb9cbd36b01bd1cbaebf2de08d9173bc095c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0x2170ed0880ac9a755fd29b2688956bd959f933f8 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0xfdc66a08b0d0dc44c17bbd471b88f49f50cdd20f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0x7130d2a12b9bcbfae4f2634d864a1ee1ce3ead9c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0x1d2f0da169ceb9fc7b3144628db156f3f6c60dbe - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0xe9e7cea3dedca5984780bafc599bd69add087d56 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0xfa54ff1a158b5189ebba6ae130ced6bbd3aea76e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0x570a5d26f7765ecb712c0924e4de545b89fd43df - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0x47c454ca6be2f6def6f32b638c80f91c9c3c5949 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0xad86d0e9764ba90ddd68747d64bffbd79879a238 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0xf8a0bf9cf54bb92f17374d9e9a321e6a111a51bd - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0xd691d9a68c887bdf34da8c36f63487333acfd103 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0x1af3f329e8be154074d8769d1ffa4ee058b1dbc3 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0x1294f4183763743c7c9519bec51773fb3acd78fd - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0xb04906e95ab5d797ada81508115611fee694c2b3 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0x111111111117dc0aa78b770fa6a738034120c302 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0xcc42724c6683b7e57334c4e856f4c9965ed682bd - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0x90c97f71e18723b0cf0dfa30ee176ab653e89f40 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0x0e09fabb73bd3ade0a17ecc321fd13a19e81ce82 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0x2b72867c32cf673f7b02d208b26889fed353b1f8 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0x031b41e504677879370e9dbcf937283a8691fa7f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0x1ce0c2827e2ef14d5c4f29a091d735a204794041 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0xcf3bb6ac0f6d987a5727e2d15e39c2d6061d5bec - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0x8ff795a6f4d97e7887c79bea79aba5cc76444adf - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0x2dff88a56767223a5529ea5960da7a3f5f766406 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0x003d87d02a2a01e9e8a20f507c83e15dd83a33d1 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0x4b0f1812e5df2a09796481ff14017e6005508003 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0xbf5140a22578168fd562dccf235e5d43a02ce9b1 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0xca1c644704febf4ab81f85daca488d1623c28e63 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0x51e72dd1f2628295cc2ef931cb64fdbdc3a0c599 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0xbbca42c60b5290f2c48871a596492f93ff0ddc82 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0x555296de6a86e72752e5c5dc091fe49713aa145c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0x0808bf94d57c905f1236212654268ef82e1e594e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0x8457ca5040ad67fdebbcc8edce889a335bc0fbfb - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0xcebef3df1f3c5bfd90fde603e71f31a53b11944d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0x90ed8f1dc86388f14b64ba8fb4bbd23099f18240 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0x9840652dc04fb9db2c43853633f0f62be6f00f98 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0xba2ae424d960c26247dd6c32edc70b295c744c43 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0x0782b6d8c4551b9760e74c0545a9bcd90bdc41e5 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0xbe2b6c5e31f292009f495ddbda88e28391c9815e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0x8f0528ce5ef7b51152a59745befdd91d97091d2f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0xffeecbf8d7267757c2dc3d13d730e97e15bfdf7f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0x0eb3a705fc54725037cc9e008bdede697f62f335 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0xf21768ccbc73ea5b6fd3c687208a7c2def2d966e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0x0000028a2eb8346cd5c0267856ab7594b7a55308 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0x76a797a59ba2c17726896976b7b3747bfd1d220f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0xc79d1fd14f514cd713b5ca43d288a782ae53eab2 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0xad29abb318791d579433d831ed122afeaf29dcfe - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0x3203c9e46ca618c8c1ce5dc67e7e9d75f5da2377 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0xdb021b1b247fe2f1fa57e0a87c748cc1e321f07f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0x7083609fce4d1d8dc0c979aab8c869ea2c873402 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0xc5f0f7b66764f6ec8c8dff7ba683102295e16409 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0xe29142e14e52bdfbb8108076f66f49661f10ec10 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0xb0d502e938ed5f4df2e681fe6e419ff29631d62b - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0x6730f7a6bbb7b9c8e60843948f7feb4b6a17b7f7 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0x1613957159e9b0ac6c80e824f7eea748a32a0ae2 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/celo/0x471ece3750da237f93b8e339c536989b8978a438 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/celo/0x765de816845861e75a25fca122bb6898b8b1282a - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/celo/0x66803fb87abd4aac3cbb3fad7c3aa01f6f3fb207 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/celo/0xd8763cba276a3738e6de85b4b3bf5fded6d6ca73 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/celo/0x37f750b7cc259a2f741af45294f6a16572cf5cad - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/celo/0xd71ffd0940c920786ec4dbb5a12306669b5b81ef - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/celo/0xe8537a3d056da446677b9e9d6c5db704eaab4787 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/celo/0x4f604735c1cf31399c6e711d5962b2b3e0225ad3 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/celo/0x02de4766c272abc10bc88c220d214a26960a7e92 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/celo/0xceba9300f2b948710d2653dd7b07f33a8b32118c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/celo/0xc16b81af351ba9e64c1a069e3ab18c244a1e3049 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x728f30fa2f100742c7949d1961804fa8e0b1387d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x41ea5d41eeacc2d5c4072260945118a13bb7ebce - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0xf21661d0d1d76d3ecb8e1b9f1c923dbfffae4097 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0xb0ecc6ac0073c063dcfc026ccdc9039cae2998e1 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/optimism/0x00f932f0fe257456b32deda4758922e56a4f4b42 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0xa4af354d466e8a68090dd9eb2cb7caf162f4c8c2 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0xba50933c268f567bdc86e1ac131be072c6b0b71a - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0xd29da236dd4aac627346e1bba06a619e8c22d7c5 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x1bfce574deff725a3f483c334b790e25c8fa9779 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x9e18d5bab2fa94a6a95f509ecb38f8f68322abd3 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0xcd5fe23c85820f7b72d0926fc9b05b43e359b7ee - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0xbf5495efe5db9ce00f80364c8b423567e58d2110 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x065b4e5dfd50ac12a81722fd0a0de81d78ddf7fb - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x57e114b691db790c35207b2e685d4a43181e6061 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x0b7f0e51cd1739d6c96982d55ad8fa634dd43a9c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0xc56c7a0eaa804f854b536a5f3d5f49d2ec4b12b8 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x594daad7d77592a2b97b725a7ad59d7e188b5bfa - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x8355dbe8b0e275abad27eb843f3eaf3fc855e525 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x2a961d752eaa791cbff05991e4613290aec0d9ac - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x38e68a37e401f7271568cecaac63c6b1e19130b4 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x1131d427ecd794714ed00733ac0f851e904c8398 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x1495bc9e44af1f8bcb62278d2bec4540cf0c05ea - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x808507121b80c02388fad14726482e061b8da827 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x44971abf0251958492fee97da3e5c5ada88b9185 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x320623b8e4ff03373931769a31fc52a4e78b5d70 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x6e5970dbd6fc7eb1f29c6d2edf2bc4c36124c0c1 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0xd40c688da9df74e03566eaf0a7c754ed98fbb8cc - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x8afe4055ebc86bd2afb3940c0095c9aca511d852 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x9ce84f6a69986a83d92c324df10bc8e64771030f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0xbe4d9c8c638b5f0864017d7f6a04b66c42953847 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x68bbed6a47194eff1cf514b50ea91895597fc91e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x69420e3a3aa9e17dea102bb3a9b3b73dcddb9528 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x7420b4b9a0110cdc71fb720908340c03f9bc03ec - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x03aa6298f1370642642415edc0db8b957783e8d6 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0xd533a949740bb3306d119cc777fa900ba034cd52 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0xf14dd7b286ce197019cba54b189d2b883e70f761 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0xa35923162c49cf95e6bf26623385eb431ad920d3 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x8cefbeb2172a9382753de431a493e21ba9694004 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x120a3879da835a5af037bb2d1456bebd6b54d4ba - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x69457a1c9ec492419344da01daf0df0e0369d5d0 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0xf6ce4be313ead51511215f1874c898239a331e37 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x73d7c860998ca3c01ce8c808f5577d94d545d1b4 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0xeff49b0f56a97c7fd3b51f0ecd2ce999a7861420 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x236501327e701692a281934230af0b6be8df3353 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x5026f006b85729a8b14553fae6af249ad16c9aab - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x66761fa41377003622aee3c7675fc7b5c1c2fac5 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x9f9c8ec3534c3ce16f928381372bfbfbfb9f4d24 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0xd8c978de79e12728e38aa952a6cb4166f891790f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x7122985656e38bdc0302db86685bb972b145bd3c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x582d872a1b094fc48f5de31d3b73f2d9be47def1 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x504624040e0642921c2c266a9ac37cafbd8cda4e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0xc548e90589b166e1364de744e6d35d8748996fe8 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x4c11249814f11b9346808179cf06e71ac328c1b5 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x423f4e6138e475d85cf7ea071ac92097ed631eea - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0x8390a1da07e376ef7add4be859ba74fb83aa02d5 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0xf94e7d0710709388bce3161c32b4eea56d3f91cc - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/ethereum/0xaa95f26e30001251fb905d264aa7b00ee9df6c18 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x2416092f143378750bb29b79ed961ab195cceea5 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x6c84a8f1c29108f47a79964b5fe888d4f4d0de40 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x71eeba415a523f5c952cc2f06361d5443545ad28 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x88a269df8fe7f53e590c561954c52fccc8ec0cfb - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x429fed88f10285e61b12bdf00848315fbdfcc341 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0xb299751b088336e165da313c33e3195b8c6663a6 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0xf0a479c9c3378638ec603b8b6b0d75903902550b - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0xb59c8912c83157a955f9d715e556257f432c35d7 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0xba0dda8762c24da9487f5fa026a9b64b695a07ea - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0xc24a365a870821eb83fd216c9596edd89479d8d7 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0xa586b3b80d7e3e8d439e25fbc16bc5bcee3e2c85 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0xef04804e1e474d3f9b73184d7ef5d786f3fce930 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x2e9a6df78e42a30712c10a9dc4b1c8656f8f2879 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x13a7dedb7169a17be92b0e3c7c2315b46f4772b3 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0x1dd6b5f9281c6b4f043c02a83a46c2772024636c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0xc5102fe9359fd9a28f877a67e36b0f050d81a3cc - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0xf525e73bdeb4ac1b0e741af3ed8a8cbb43ab0756 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0xe4177c1400a8eee1799835dcde2489c6f0d5d616 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0xed5740209fcf6974d6f3a5f11e295b5e468ac27c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/arbitrum/0xe10d4a4255d2d35c9e23e2c4790e073046fbaf5c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/optimism/0x10398abc267496e49106b07dd6be13364d10dc71 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/optimism/0x2218a117083f5b482b0bb821d27056ba9c04b1d3 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/optimism/0x395ae52bb17aef68c2888d941736a71dc6d4e125 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/optimism/0x9a601c5bb360811d96a23689066af316a30c3027 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0xbac3368b5110f3a3dda8b5a0f7b66edb37c47afe - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x1d3c629ca5c1d0ab3bdf74600e81b4145615df8e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0xe9c21de62c5c5d0ceacce2762bf655afdceb7ab3 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x658cda444ac43b0a7da13d638700931319b64014 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x3d2bd0e15829aa5c362a4144fdf4a1112fa29b5c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x3fb83a9a2c4408909c058b0bfe5b4823f54fafe2 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x00e5646f60ac6fb446f621d146b6e1886f002905 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x12a4cebf81f8671faf1ab0acea4e3429e42869e7 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x9ff62d1fc52a907b6dcba8077c2ddca6e6a9d3e1 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0xc61f39418cd27820b5d4e9ba4a7197eefaeb8b05 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x15b7c0c907e4c6b9adaaaabc300c08991d6cea05 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x7f67639ffc8c93dd558d452b8920b28815638c44 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/polygon/0x276c9cbaa4bdf57d7109a41e67bd09699536fa3d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x041fdf3f472d2c8a7ecc458fc3b7f543e6c57ef7 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x3c281a39944a2319aa653d81cfd93ca10983d234 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x96419929d7949d6a801a6909c145c8eef6a40431 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0xfea9dcdc9e23a9068bf557ad5b186675c61d33ea - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0xdb6e0e5094a25a052ab6845a9f1e486b9a9b3dde - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0xcde172dc5ffc46d228838446c57c1227e0b82049 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0xff0c532fdb8cd566ae169c1cb157ff2bdc83e105 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x9a26f5433671751c3276a065f57e5a02d2817973 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x3636a7734b669ce352e97780df361ce1f809c58c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x50c5725949a6f0c72e6c4a641f24049a917db0cb - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0xe3086852a4b125803c815a158249ae468a3254ca - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0xbeb0fd48c2ba0f1aacad2814605f09e08a96b94e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0xbc45647ea894030a4e9801ec03479739fa2485f0 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x768be13e1680b5ebe0024c42c896e3db59ec0149 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x928a6a9fc62b2c94baf2992a6fba4715f5bb0066 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0xbf4db8b7a679f89ef38125d5f84dd1446af2ea3b - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0xed899bfdb28c8ad65307fa40f4acab113ae2e14c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x1b6a569dd61edce3c383f6d565e2f79ec3a12980 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x76734b57dfe834f102fb61e1ebf844adf8dd931e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x4621b7a9c75199271f773ebd9a499dbd165c3191 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0xaf07d812d1dcec20bf741075bc18660738d226dd - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x7f12d13b34f5f4f0a9449c16bcd42f0da47af200 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x55a6f6cb50db03259f6ab17979a4891313be2f45 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x968d6a288d7b024d5012c0b25d67a889e4e3ec19 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x7a8a5012022bccbf3ea4b03cd2bb5583d915fb1a - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0xcde90558fc317c69580deeaf3efc509428df9080 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x0028e1e60167b48a938b785aa5292917e7eaca8b - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x76e7447bafa3f0acafc9692629b1d1bc937ca15d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x15ac90165f8b45a80534228bdcb124a011f62fee - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x4045b33f339a3027af80013fb5451fdbb01a4492 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0xddf98aad8180c3e368467782cd07ae2e3e8d36a5 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x698dc45e4f10966f6d1d98e3bfd7071d8144c233 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x3c8665472ec5af30981b06b4e0143663ebedcc1e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x18a8bd1fe17a1bb9ffb39ecd83e9489cfd17a022 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0xba0dda8762c24da9487f5fa026a9b64b695a07ea - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x13741c5df9ab03e7aa9fb3bf1f714551dd5a5f8a - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0xebff2db643cf955247339c8c6bcd8406308ca437 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0xfadb26be94c1f959f900bf88cd396b3e803481d6 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x52c2b317eb0bb61e650683d2f287f56c413e4cf6 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x38d513ec43dda20f323f26c7bef74c5cf80b6477 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x33ad778e6c76237d843c52d7cafc972bb7cf8729 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x290814ad0fbd2b935f34d7b40306102313d4c63e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x5e432eecd01c12ee7071ee9219c2477a347da192 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0xbdf5bafee1291eec45ae3aadac89be8152d4e673 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0xff62ddfa80e513114c3a0bf4d6ffff1c1d17aadf - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x8c81b4c816d66d36c4bf348bdec01dbcbc70e987 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x6b82297c6f1f9c3b1f501450d2ee7c37667ab70d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x42069babe14fb1802c5cb0f50bb9d2ad6fef55e2 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x72499bddb67f4ca150e1f522ca82c87bc9fb18c8 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x0578d8a44db98b23bf096a382e016e29a5ce0ffe - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x8fe815417913a93ea99049fc0718ee1647a2a07c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x7d12aeb5d96d221071d176980d23c213d88d9998 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0xb166e8b140d35d9d8226e40c09f757bac5a4d87d - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x8853f0c059c27527d33d02378e5e4f6d5afb574a - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0xf3c052f2baab885c610a748eb01dfbb643ba835b - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0xcd1cffa8ebc66f1a2cf7675b48ba955ffcb82d8e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0xde7a416ac821c77478340eebaa21b68297025ef3 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x2da56acb9ea78330f947bd57c54119debda7af71 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x8972ab69d499b5537a31576725f0af8f67203d38 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x88faea256f789f8dd50de54f9c807eef24f71b16 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x42069de48741db40aef864f8764432bbccbd0b69 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x9a27c6759a6de0f26ac41264f0856617dec6bc3f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0xfaa4f3bcfc87d791e9305951275e0f62a98bcb10 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0xfd9fa4f785331ce88b5af8994a047ba087c705d8 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x21eceaf3bf88ef0797e3927d855ca5bb569a47fc - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x7d9ce55d54ff3feddb611fc63ff63ec01f26d15f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x4229c271c19ca5f319fb67b4bc8a40761a6d6299 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x80f45eacf6537498ecc660e4e4a2d2f99e195cf4 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x1a475d06d967aeb686c98de80d079d72097aeacf - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x4fb9b20dafe45d91ae287f2e07b2e79709308178 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0xd3741ac9b3f280b0819191e4b30be4ecd990771e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x09579452bc3872727a5d105f342645792bb8a82b - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x8a24d7260cd02d3dfd8eefb66bc17ad4b17d494c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0xd88611a629265c9af294ffdd2e7fa4546612273e - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x9a86980d3625b4a6e69d8a4606d51cbc019e2002 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x1c7a460413dd4e964f96d8dfc56e7223ce88cd85 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x776aaef8d8760129a0398cf8674ee28cefc0eab9 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x28e29ec91db66733a94ee8e3b86a6199117baf99 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0xb9898511bd2bad8bfc23eba641ef97a08f27e730 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x76baa16ff15d61d32e6b3576c3a8c83a25c2f180 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x2816a491dd0b7a88d84cbded842a618e59016888 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0xa7ea9d5d4d4c7cf7dbde5871e6d108603c6942a5 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/base/0x586e10db93630a4d2da6c6a34ba715305b556f04 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0xf486ad071f3bee968384d2e39e2d8af0fcf6fd46 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0x76d36d44dc4595e8d2eb3ad745f175eda134284f - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0x1fa4a73a3f0133f0025378af00236f3abdee5d63 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0xb3ed0a426155b79b898849803e3b36552f7ed507 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0x0ef4a107b48163ab4b57fca36e1352151a587be4 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0x62694d43ccb9b64e76e38385d15e325c7712a735 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0xa2b726b1145a4773f68593cf171187d8ebe4d495 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0xf275e1ac303a4c9d987a2c48b8e555a77fec3f1c - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/bnb/0x11a31b833d43853f8869c9eec17f60e3b4d2a753 - 2024-07-05T19:43:14.783Z + 2024-05-20T17:20:52.753Z 0.8 https://app.uniswap.org/explore/tokens/celo/0x48065fbbe25f71c9282ddf5e1cd6d6a887483d5e - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0xbadff0ef41d2a68f22de21eabca8a59aaf495cf0 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x1fdd61ef9a5c31b9a2abc7d39c139c779e8412af - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x4ade2b180f65ed752b6f1296d0418ad21eb578c0 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x0c5cb676e38d6973837b9496f6524835208145a2 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0xb69753c06bb5c366be51e73bfc0cc2e3dc07e371 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x8143182a775c54578c8b7b3ef77982498866945d - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x76e222b07c53d28b89b0bac18602810fc22b49a8 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x18aaa7115705e8be94bffebde57af9bfc265b998 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x7d8146cf21e8d7cbe46054e01588207b51198729 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0xfe0c30065b384f05761f15d0cc899d4f9f9cc0eb - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x1ce270557c1f68cfb577b856766310bf8b47fd9c - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x793a5d8b30aab326f83d20a9370c827fea8fdc51 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0xff836a5821e69066c87e268bc51b849fab94240c - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0xf4d2888d29d722226fafa5d9b24f9164c092421e - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x8ed97a637a790be1feff5e888d43629dc05408f6 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x31c8eacbffdd875c74b94b077895bd78cf1e64a3 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0xc55126051b22ebb829d00368f4b12bde432de5da - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0xe0f63a424a4439cbe457d80e4f4b51ad25b2c56c - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x8881562783028f5c1bcb985d2283d5e170d88888 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x67466be17df832165f8c80a5a120ccc652bd7e69 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0xd939212f16560447ed82ce46ca40a63db62419b5 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x88417754ff7062c10f4e3a4ab7e9f9d9cbda6023 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x5afe3855358e112b5647b952709e6165e1c1eeee - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x02e7f808990638e9e67e1f00313037ede2362361 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0xd2bdaaf2b9cc6981fd273dcb7c04023bfbe0a7fe - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x112b08621e27e10773ec95d250604a041f36c582 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x32b053f2cba79f80ada5078cb6b305da92bde6e1 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x5ac34c53a04b9aaa0bf047e7291fb4e8a48f2a18 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x26ebb8213fb8d66156f1af8908d43f7e3e367c1d - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0xe3b9cfb8ea8a4f1279fbc28d3e15b4d2d86f18a0 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x8207c1ffc5b6804f6024322ccf34f29c3541ae26 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x255f1b39172f65dc6406b8bee8b08155c45fe1b6 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/arbitrum/0x092baadb7def4c3981454dd9c0a0d7ff07bcfc86 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/arbitrum/0x53bcf6698c911b2a7409a740eacddb901fc2a2c6 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/arbitrum/0x2ac2b254bc18cd4999f64773a966e4f4869c34ee - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/arbitrum/0x17fc002b466eec40dae837fc4be5c67993ddbd6f - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/arbitrum/0xc8a4eea31e9b6b61c406df013dd4fec76f21e279 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/arbitrum/0x498bf2b1e120fed3ad3d42ea2165e9b73f99c1e5 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/arbitrum/0xe4dddfe67e7164b0fe14e218d80dc4c08edc01cb - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/arbitrum/0x7c8a1a80fdd00c9cccd6ebd573e9ecb49bfa2a59 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/arbitrum/0x1debd73e752beaf79865fd6446b0c970eae7732f - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/arbitrum/0xaf5db6e1cc585ca312e8c8f7c499033590cf5c98 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/optimism/0x65559aa14915a70190438ef90104769e5e890a00 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/optimism/0x7fb688ccf682d58f86d7e38e03f9d22e7705448b - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/optimism/0x73cb180bf0521828d8849bc8cf2b920918e23032 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/optimism/0x2e3d870790dc77a83dd1d18184acc7439a53f475 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/optimism/0xa00e3a3511aac35ca78530c85007afcd31753819 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/optimism/0x528cdc92eab044e1e39fe43b9514bfdab4412b98 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/optimism/0x4f604735c1cf31399c6e711d5962b2b3e0225ad3 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/polygon/0x1c954e8fe737f99f68fa1ccda3e51ebdb291948c - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/polygon/0xf50d05a1402d0adafa880d36050736f9f6ee7dee - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/polygon/0xab0b2ddb9c7e440fac8e140a89c0dbcbf2d7bbff - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/polygon/0x8bc3ec2e7973e64be582a90b08cadd13457160fe - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/polygon/0x64060ab139feaae7f06ca4e63189d86adeb51691 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/polygon/0x5ec03c1f7fa7ff05ec476d19e34a22eddb48acdc - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/polygon/0x9627a3d6872be48410fcece9b1ddd344bf08c53e - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/polygon/0x1ed02954d60ba14e26c230eec40cbac55fa3aeea - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x8d3419b9a18651f3926a205ee0b1acea1e7192de - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0xb56d0839998fd79efcd15c27cf966250aa58d6d3 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x81f91fe59ee415735d59bd5be5cca91a0ea4fa69 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x87c211144b1d9bdaa5a791b8099ea4123dc31d21 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0xf4210f93bc68d63df3286c73eba08c6414f40c0d - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0xece7b98bd817ee5b1f2f536daf34d0b6af8bb542 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x4c96a67b0577358894407af7bc3158fc1dffbeb5 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x70737489dfdf1a29b7584d40500d3561bd4fe196 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x39353a32eceafe4979a8606512c046c3b6398cc4 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x92fb1b7d9730b2f1bd4e2e91368c1eb6fdd2a009 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x174e33ef2effa0a4893d97dda5db4044cc7993a3 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0xfdc944fb59201fb163596ee5e209ebc8fa4dcdc5 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x388e543a5a491e7b42e3fbcd127dd6812ea02d0d - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x56a38e7216304108e841579041249feb236c887b - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x1804e3db872eed4141e482ff74c56862f2791103 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x9de16c805a3227b9b92e39a446f9d56cf59fe640 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0xb8d98a102b0079b69ffbc760c8d857a31653e56e - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x5d6812722c3693078e4a0dbe3e9affc27a0b2768 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x255f1b39172f65dc6406b8bee8b08155c45fe1b6 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0xc2fe011c3885277c7f0e7ffd45ff90cadc8ecd12 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0xc1ffaef4e7d553bbaf13926e258a1a555a363a07 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x4e73420dcc85702ea134d91a262c8ffc0a72aa70 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0xecaf81eb42cd30014eb44130b89bcd6d4ad98b92 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/bnb/0x4eae52907dba9c370e9ee99f0ce810602a4f2c63 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/bnb/0x25d887ce7a35172c62febfd67a1856f20faebb00 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x382ea807a61a418479318efd96f1efbc5c1f2c21 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x6468e79a80c0eab0f9a2b574c8d5bc374af59414 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x3106a0a076bedae847652f42ef07fd58589e001f - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0xd015422879a1308ba557510345e944b912b9ab73 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x5de8ab7e27f6e7a1fff3e5b337584aa43961beef - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0xcf078da6e85389de507ceede0e3d217e457b9d49 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x1bbf25e71ec48b84d773809b4ba55b6f4be946fb - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x7039cd6d7966672f194e8139074c3d5c4e6dcf65 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x943af17c37207c9d7a27d12cb5055542a0b7afa8 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x6d68015171eaa7af9a5a0a103664cf1e506ff699 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x6942806d1b2d5886d95ce2f04314ece8eb825833 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x949d48eca67b17269629c7194f4b727d4ef9e5d6 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x9361adf2b72f413d96f81ff40d794b47ce13b331 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x3bb1be077f3f96722ae92ec985ab37fd0a0c4c51 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0xdbb7a34bf10169d6d2d0d02a6cbb436cf4381bfa - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x66bff695f3b16a824869a8018a3a6e3685241269 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x85d19fb57ca7da715695fcf347ca2169144523a7 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x069d89974f4edabde69450f9cf5cf7d8cbd2568d - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x0fe13ffe64b28a172c58505e24c0c111d149bd47 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x111111111117dc0aa78b770fa6a738034120c302 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0xdc7ac5d5d4a9c3b5d8f3183058a92776dc12f4f3 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x482702745260ffd69fc19943f70cffe2cacd70e9 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0xc555d625828c4527d477e595ff1dd5801b4a600e - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x9eec1a4814323a7396c938bc86aec46b97f1bd82 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x87d73e916d7057945c9bcd8cdd94e42a6f47f776 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x067def80d66fb69c276e53b641f37ff7525162f6 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0xdd157bd06c1840fa886da18a138c983a7d74c1d7 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/arbitrum/0xe80772eaf6e2e18b651f160bc9158b2a5cafca65 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/arbitrum/0xb6093b61544572ab42a0e43af08abafd41bf25a6 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/arbitrum/0x35ca1e5a9b1c09fa542fa18d1ba4d61c8edff852 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/arbitrum/0x83e60b9f7f4db5cdb0877659b1740e73c662c55b - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/arbitrum/0x4d01397994aa636bdcc65c9e8024bc497498c3bb - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/arbitrum/0xc3abc47863524ced8daf3ef98d74dd881e131c38 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/arbitrum/0x4d15a3a2286d883af0aa1b3f21367843fac63e07 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/polygon/0xfb7f8a2c0526d01bfb00192781b7a7761841b16c - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/polygon/0x3809dcdd5dde24b37abe64a5a339784c3323c44f - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/polygon/0x85955046df4668e1dd369d2de9f3aeb98dd2a369 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/polygon/0x554cd6bdd03214b10aafa3e0d4d42de0c5d2937b - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/polygon/0x4318cb63a2b8edf2de971e2f17f77097e499459d - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/polygon/0xab9cb20a28f97e189ca0b666b8087803ad636b3c - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/polygon/0x6a8ec2d9bfbdd20a7f5a4e89d640f7e7ceba4499 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/polygon/0x385eeac5cb85a38a9a07a70c73e0a3271cfb54a7 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/polygon/0x0169ec1f8f639b32eec6d923e24c2a2ff45b9dd6 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0xe161be4a74ab8fa8706a2d03e67c02318d0a0ad6 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x4d58608eff50b691a3b76189af2a7a123df1e9ba - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x420b0fa3de2efcf2b2fd04152eb1df36a09717cd - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x1cd38856ee0fdfd65c757e530e3b1de3061008d3 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0xfad8cb754230dbfd249db0e8eccb5142dd675a0d - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0xda761a290e01c69325d12d82ac402e5a73d62e81 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0xafb5d4d474693e68df500c9c682e6a2841f9661a - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x0b3e328455c4059eeb9e3f84b5543f74e24e7e1b - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0xfc5462143a3178cf044e97c491f6bcb5e38f173e - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0xed1978d01d4a8a9d6a43ac79403d5b8dfbed739b - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0xba71cb8ef2d59de7399745793657838829e0b147 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x10c1b6f768e13c624a4a23337f1a5ba5c9be0e4b - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x1b1514c76c54ce8807d7fdedf85c664eee734ece - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x58cd93c4a91c3940109fa27d700f5013b18b5dc2 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0xea6f7e7e0f46a9e0f4e2048eb129d879f609d632 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x30d19fb77c3ee5cfa97f73d72c6a1e509fa06aef - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/bnb/0xe2dca969624795985f2f083bcd0b674337ba130a - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/bnb/0xbb7d61d2511fd2e63f02178ca9b663458af9fc63 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/bnb/0x59f4f336bf3d0c49dbfba4a74ebd2a6ace40539a - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/bnb/0x62d0a8458ed7719fdaf978fe5929c6d342b0bfce - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0xb8fda5aee55120247f16225feff266dfdb381d4c - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0xca530408c3e552b020a2300debc7bd18820fb42f - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x3ffeea07a27fab7ad1df5297fa75e77a43cb5790 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0xcfeb09c3c5f0f78ad72166d55f9e6e9a60e96eec - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x467bccd9d29f223bce8043b84e8c8b282827790f - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x2077d81d0c5258230d5a195233941547cb5f0989 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x7d1afa7b718fb893db30a3abc0cfc608aacfebb0 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0xa0bbbe391b0d0957f1d013381b643041d2ca4022 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0xd1b89856d82f978d049116eba8b7f9df2f342ff3 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x62f03b52c377fea3eb71d451a95ad86c818755d1 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x7fc66500c84a76ad7e9c93437bfc5ac33e2ddae9 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x3927fb89f34bbee63351a6340558eebf51a19fb8 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0xacd2c239012d17beb128b0944d49015104113650 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x86b69f38bea3e02f68ff88534bc61ec60e772b19 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x6873c95307e13beb58fb8fcddf9a99667655c9e4 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x18084fba666a33d37592fa2633fd49a74dd93a88 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x6e79b51959cf968d87826592f46f819f92466615 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x80ee5c641a8ffc607545219a3856562f56427fe9 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x0414d8c87b271266a5864329fb4932bbe19c0c49 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0xf57e7e7c23978c3caec3c3548e3d615c346e79ff - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/arbitrum/0xb0ffa8000886e57f86dd5264b9582b2ad87b2b91 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/arbitrum/0x1c986661170c1834db49c3830130d4038eeeb866 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/arbitrum/0x9ed7e4b1bff939ad473da5e7a218c771d1569456 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/arbitrum/0x7f9a7db853ca816b9a138aee3380ef34c437dee0 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/arbitrum/0x371c7ec6d8039ff7933a2aa28eb827ffe1f52f07 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/arbitrum/0xb1bc21f748ae2be95674876710bc6d78235480e0 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/arbitrum/0xadf5dd3e51bf28ab4f07e684ecf5d00691818790 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/optimism/0x1eba7a6a72c894026cd654ac5cdcf83a46445b08 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/polygon/0x38022a157b95c52d43abcac9bd09f028a1079105 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/polygon/0xd2507e7b5794179380673870d88b22f94da6abe0 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/polygon/0xc708d6f2153933daa50b2d0758955be0a93a8fec - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/polygon/0x0052074d3eb1429f39e5ea529b54a650c21f5aa4 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/polygon/0x4e78011ce80ee02d2c3e649fb657e45898257815 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/polygon/0x7583feddbcefa813dc18259940f76a02710a8905 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/polygon/0xe78aee6ccb05471a69677fb74da80f5d251c042b - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/polygon/0x04f177fcacf6fb4d2f95d41d7d3fee8e565ca1d0 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/polygon/0xa6da8c8999c094432c77e7d318951d34019af24b - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x6d3b8c76c5396642960243febf736c6be8b60562 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x7cf7132ede0ca592a236b6198a681bb7b42dd5ae - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x3afeae00a594fbf2e4049f924e3c6ac93296b6e8 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x0a93a7be7e7e426fc046e204c44d6b03a302b631 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0xc9b6ef062fab19d3f1eabc36b1f2e852af1acd18 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x1754e5aadce9567a95f545b146a616ce34eead53 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0xdb173587d459ddb1b9b0f2d6d88febef039304a2 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x10a7a84c91988138f8dbbc82a23b02c8639e2552 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x92af6f53febd6b4c6f5293840b6076a1b82c4bc2 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0xeb9e49fb4c33d9f6aefb1b03f9133435e24c0ec6 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x1b2c141479757b8643a519be4692904088d860b2 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x4d25e94291fe8dcfbfa572cbb2aaa7b755087c91 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x8e0e798966382e53bfb145d474254cbe065c17dc - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x4b6f82a4ed0b9e3767f53309b87819a78d041a7f - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x004aa1586011f3454f487eac8d0d5c647d646c69 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x741777f6b6d8145041f73a0bddd35ae81f55a40f - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/bnb/0xc6c58f600917de512cd02d2b6ed595ab54b4c30f - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/bnb/0x03aa6298f1370642642415edc0db8b957783e8d6 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/bnb/0x3ee2200efb3400fabb9aacf31297cbdd1d435d47 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/bnb/0x0d8ce2a99bb6e3b7db580ed848240e4a0f9ae153 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/bnb/0xa697e272a73744b343528c3bc4702f2565b2f422 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/bnb/0x301af3eff0c904dc5ddd06faa808f653474f7fcc - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/bnb/0x776f9987d9deed90eed791cbd824d971fd5ccf09 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/bnb/0xf7de7e8a6bd59ed41a4b5fe50278b3b7f31384df - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/bnb/0x19e6bfc1a6e4b042fb20531244d47e252445df01 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/bnb/0x4338665cbb7b2485a8855a139b75d5e34ab0db94 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/bnb/0x2940566eb50f15129238f4dc599adc4f742d7d8e - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/bnb/0xbb73bb2505ac4643d5c0a99c2a1f34b3dfd09d11 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/bnb/0x4ea98c1999575aaadfb38237dd015c5e773f75a2 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/celo/0x1d18d0386f51ab03e7e84e71bda1681eba865f1f - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x57b96d4af698605563a4653d882635da59bf11af - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0xd33526068d116ce69f19a9ee46f0bd304f21a51f - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x2a5fa016ffb20c70e2ef36058c08547f344677aa - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0xbe0ed4138121ecfc5c0e56b40517da27e6c5226b - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x9fd9278f04f01c6a39a9d1c1cd79f7782c6ade08 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x054c9d4c6f4ea4e14391addd1812106c97d05690 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x7613c48e0cd50e42dd9bf0f6c235063145f6f8dc - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x614da3b37b6f66f7ce69b4bbbcf9a55ce6168707 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x069e4aa272d17d9625aa3b6f863c7ef6cfb96713 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x24da31e7bb182cb2cabfef1d88db19c2ae1f5572 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x7d4a23832fad83258b32ce4fd3109ceef4332af4 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0xb58e61c3098d85632df34eecfb899a1ed80921cb - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x67c4d14861f9c975d004cfb3ac305bee673e996e - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x69babe9811cc86dcfc3b8f9a14de6470dd18eda4 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x32f0d04b48427a14fb3cbc73db869e691a9fec6f - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x4cff49d0a19ed6ff845a9122fa912abcfb1f68a6 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x51cb253744189f11241becb29bedd3f1b5384fdb - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0xcf4c91ecafc43c9f382db723ba20b82efa852821 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x6968676661ac9851c38907bdfcc22d5dd77b564d - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x0d438f3b5175bebc262bf23753c1e53d03432bde - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0xb98d4c97425d9908e66e53a6fdf673acca0be986 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x68a47fe1cf42eba4a030a10cd4d6a1031ca3ca0a - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x8a370c951f34e295b2655b47bb0985dd08d8f718 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x525574c899a7c877a11865339e57376092168258 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0xd9a442856c234a39a81a089c06451ebaa4306a72 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/arbitrum/0x1c43d05be7e5b54d506e3ddb6f0305e8a66cd04e - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/arbitrum/0xb766039cc6db368759c1e56b79affe831d0cc507 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/arbitrum/0x18c14c2d707b2212e17d1579789fc06010cfca23 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/arbitrum/0xe0ee18eacafddaeb38f8907c74347c44385578ab - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/arbitrum/0x56659245931cb6920e39c189d2a0e7dd0da2d57b - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/polygon/0xb6a5ae40e79891e4deadad06c8a7ca47396df21c - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/polygon/0x04565fe9aa3ae571ada8e1bebf8282c4e5247b2a - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0xf8a99f2bf2ce5bb6ce4aafcf070d8723bc904aa2 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x3b9728bd65ca2c11a817ce39a6e91808cceef6fd - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x6797b6244fa75f2e78cdffc3a4eb169332b730cc - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0xe2c86869216ac578bd62a4b8313770d9ee359a05 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x47b464edb8dc9bc67b5cd4c9310bb87b773845bd - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x28a730de97dc62a8c88363e0b1049056f1274a70 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0xba5ede8d98ab88cea9f0d69918dde28dc23c2553 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x8319767a7b602f88e376368dca1b92d38869b9b4 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x461ee40928677644b8195662ab91bcdaae6ef105 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x24569d33653c404f90af10a2b98d6e0030d3d267 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x22222bd682745cf032006394750739684e45a5f8 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x9124577428c5bd73ad7636cbc5014081384f29d6 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0xaa6cccdce193698d33deb9ffd4be74eaa74c4898 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0xe095780ba2a64a4efa7a74830f0b71656f0b0ad4 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0xb59c8912c83157a955f9d715e556257f432c35d7 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x7771450ece9c61430953d2646f995e33a06c91f5 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0xc48823ec67720a04a9dfd8c7d109b2c3d6622094 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/bnb/0x9ec02756a559700d8d9e79ece56809f7bcc5dc27 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x3593d125a4f7849a1b059e64f4517a86dd60c95d - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0xb0ffa8000886e57f86dd5264b9582b2ad87b2b91 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x6985884c4392d348587b19cb9eaaf157f13271cd - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0xa045fe936e26e1e1e1fb27c1f2ae3643acde0171 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0xbeef698bd78139829e540622d5863e723e8715f1 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x426a688ee72811773eb64f5717a32981b56f10c1 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x873259322be8e50d80a4b868d186cc5ab148543a - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x661c70333aa1850ccdbae82776bb436a0fcfeefb - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x0a2c375553e6965b42c135bb8b15a8914b08de0c - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x6fba952443be1de22232c824eb8d976b426b3c38 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x1abaea1f7c830bd89acc67ec4af516284b1bc33c - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0xb62132e35a6c13ee1ee0f84dc5d40bad8d815206 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0xb60fdf036f2ad584f79525b5da76c5c531283a1b - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x5a3e6a77ba2f983ec0d371ea3b475f8bc0811ad5 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x55296f69f40ea6d20e478533c15a6b08b654e758 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x1a7e4e63778b4f12a199c062f3efdd288afcbce8 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x45804880de22913dafe09f4980848ece6ecbaf78 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0xe5018913f2fdf33971864804ddb5fca25c539032 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/arbitrum/0x6985884c4392d348587b19cb9eaaf157f13271cd - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/arbitrum/0x2c650dab03a59332e2e0c0c4a7f726913e5028c1 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/arbitrum/0x9aee3c99934c88832399d6c6e08ad802112ebeab - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/arbitrum/0x439c0cf1038f8002a4cad489b427e217ba4b42ad - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/optimism/0x6985884c4392d348587b19cb9eaaf157f13271cd - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/polygon/0x6985884c4392d348587b19cb9eaaf157f13271cd - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x6985884c4392d348587b19cb9eaaf157f13271cd - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0xb79dd08ea68a908a97220c76d19a6aa9cbde4376 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x4b61e2f1bbdee6d746209a693156952936f1702c - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x7480527815ccae421400da01e052b120cc4255e9 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x7466de7bb8b5e41ee572f4167de6be782a7fa75d - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x298d411511a05dc1b559ed8f79c56bee06687b14 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x8e16d46cb2da01cdd49601ec73d7b0344969ae33 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x18dd5b087bca9920562aff7a0199b96b9230438b - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x37f0c2915cecc7e977183b8543fc0864d03e064c - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x37f24b26bcefbfac7f261b97f8036da98f81a299 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0xacb5b33ce55ba7729e38b2b59677e71c0112f0d9 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/bnb/0x6985884c4392d348587b19cb9eaaf157f13271cd - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/bnb/0xc71b5f631354be6853efe9c3ab6b9590f8302e81 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x7e744bbb1a49a44dfcc795014a4ba618e418fbbe - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x4e3fbd56cd56c3e72c1403e103b45db9da5b9d2b - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x0c04ff41b11065eed8c9eda4d461ba6611591395 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x636bd98fc13908e475f56d8a38a6e03616ec5563 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x590246bfbf89b113d8ac36faeea12b7589f7fe5b - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x80034f803afb1c6864e3ca481ef1362c54d094b9 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x73fbd93bfda83b111ddc092aa3a4ca77fd30d380 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0xff33a6b3dc0127862eedd3978609404b22298a54 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0xc770eefad204b5180df6a14ee197d99d808ee52d - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0xa0385e7283c83e2871e9af49eec0966088421ddd - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0xb2617246d0c6c0087f18703d576831899ca94f01 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0xba386a4ca26b85fd057ab1ef86e3dc7bdeb5ce70 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x9ebb0895bd9c7c9dfab0d8d877c66ba613ac98ea - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0xd12a99dbc40036cec6f1b776dccd2d36f5953b94 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x8ab2ff0116a279a99950c66a12298962d152b83c - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x420698cfdeddea6bc78d59bc17798113ad278f9d - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0xa8c8cfb141a3bb59fea1e2ea6b79b5ecbcd7b6ca - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0xd8e8438cf7beed13cfabc82f300fb6573962c9e3 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0xb1c9d42fa4ba691efe21656a7e6953d999b990c4 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/arbitrum/0xdadeca1167fe47499e53eb50f261103630974905 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/arbitrum/0xa05245ade25cc1063ee50cf7c083b4524c1c4302 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/polygon/0x4fafad147c8cd0e52f83830484d164e960bdc6c3 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x4dd9077269dd08899f2a9e73507125962b5bc87f - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x8931ee05ec111325c1700b68e5ef7b887e00661d - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x26f1bb40ea88b46ceb21557dc0ffac7b7c0ad40f - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x642e993fa91ffe9fb24d39a8eb0e0663145f8e92 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x0c41f1fc9022feb69af6dc666abfe73c9ffda7ce - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0xf7ccb8a6e3400eb8eb0c47619134f7516e025215 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x2416092f143378750bb29b79ed961ab195cceea5 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0xf0268c5f9aa95baf5c25d646aabb900ac12f0800 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x0c067fc190cde145b0c537765a78d4e19873a5cc - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0xbe5614875952b1683cb0a2c20e6509be46d353a4 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x87a0233a8cb4392ec3eb8fa467817fc0b6a326dd - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0xdfbea88c4842d30c26669602888d746d30f9d60d - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0xb6fe221fe9eef5aba221c348ba20a1bf5e73624c - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x80b3455e1db60b4cba46aba12e8b1e256dd64979 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/bnb/0x747747e47a48c669be384e0dfb248eee6ba04039 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/celo/0x50e85c754929840b58614f48e29c64bc78c58345 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x02f92800f57bcd74066f5709f1daa1a4302df875 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x967da4048cd07ab37855c090aaf366e4ce1b9f48 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x729031b3995538ddf6b6bce6e68d5d6fdeb3ccb5 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x6dea81c8171d0ba574754ef6f8b412f2ed88c54d - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x97a9a15168c22b3c137e6381037e1499c8ad0978 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x5faa989af96af85384b8a938c2ede4a7378d9875 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x4691937a7508860f876c9c0a2a617e7d9e945d4b - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0xb50721bcf8d664c30412cfbc6cf7a15145234ad1 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x037a54aab062628c9bbae1fdb1583c195585fe41 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/arbitrum/0xcb8b5cd20bdcaea9a010ac1f8d835824f5c87a04 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/arbitrum/0xdfb8be6f8c87f74295a87de951974362cedcfa30 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/arbitrum/0x354a6da3fcde098f8389cad84b0182725c6c91de - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/arbitrum/0x3f56e0c36d275367b8c502090edf38289b3dea0d - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/arbitrum/0x6f9590958ce2beaf9c92a3a8fca6d1ddf310e052 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/optimism/0x3e5d9d8a63cc8a88748f229999cf59487e90721e - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/optimism/0xecc68d0451e20292406967fe7c04280e5238ac7d - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/polygon/0xf1c1a3c2481a3a8a3f173a9ab5ade275292a6fa3 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/polygon/0xb5e0cfe1b4db501ac003b740665bf43192cc7853 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/polygon/0xffa188493c15dfaf2c206c97d8633377847b6a52 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/polygon/0xb5c064f955d8e7f38fe0460c556a72987494ee17 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/polygon/0x4f604735c1cf31399c6e711d5962b2b3e0225ad3 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/polygon/0xf0949dd87d2531d665010d6274f06a357669457a - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/polygon/0x14e5386f47466a463f85d151653e1736c0c50fc3 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/polygon/0xadac33f543267c4d59a8c299cf804c303bc3e4ac - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0xcfa3ef56d303ae4faaba0592388f19d7c3399fb4 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x67ce18961c3269ca03c2e5632f1938cc53e614a1 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x48164ea5df090e80a0eaee1147e466ea28669221 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x3054e8f8fba3055a42e5f5228a2a4e2ab1326933 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x42069d11a2cc72388a2e06210921e839cfbd3280 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x74ff3cbf86f95fea386f79633d7bc4460d415f34 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x2d6a3893966dda77749cc7e4003ab15f5cfa3cc1 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x51b75da3da2e413ea1b8ed3eb078dc712304761c - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x8ad5b9007556749de59e088c88801a3aaa87134b - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0xbd97693278f1948c59f65f130fd87e7ff7c61d11 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x3992b27da26848c2b19cea6fd25ad5568b68ab98 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/bnb/0x34980c35353a8d7b1a1ba02e02e387a8383e004a - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/bnb/0xdebd6e2da378784a69dc6ec99fe254223b312287 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/celo/0x456a3d042c0dbd3db53d5489e98dfb038553b0d0 - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/celo/0x9995cc8f20db5896943afc8ee0ba463259c931ed - 2024-07-05T19:43:14.783Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x30d20208d987713f46dfd34ef128bb16c404d10f - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x19848077f45356b21164c412eff3d3e4ff6ebc31 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x53206bf5b6b8872c1bb0b3c533e06fde2f7e22e4 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x07ddacf367f0d40bd68b4b80b4709a37bdc9f847 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0xbdbe9f26918918bd3f43a0219d54e5fda9ce1bb3 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0xb9d09bc374577dac1ab853de412a903408204ea8 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0xe72b141df173b999ae7c1adcbf60cc9833ce56a8 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x214549b0317564de15770561221433fb3e8c995c - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0xc82e3db60a52cf7529253b4ec688f631aad9e7c2 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0xf3dcbc6d72a4e1892f7917b7c43b74131df8480e - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x62e3b3c557c792c4a70765b3cdb5b56b1879f82d - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x2598c30330d5771ae9f983979209486ae26de875 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0xd4f4d0a10bcae123bb6655e8fe93a30d01eebd04 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/arbitrum/0xa0995d43901551601060447f9abf93ebc277cec2 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/polygon/0x40379a439d4f6795b6fc9aa5687db461677a2dba - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/polygon/0x433cde5a82b5e0658da3543b47a375dffd126eb6 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x619c4bbbd65f836b78b36cbe781513861d57f39d - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x1e0bb24ed6c806c01ef2f880a4b91adb90099ea7 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x0dd7913197bfb6d2b1f03f9772ced06298f1a644 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0xfbb75a59193a3525a8825bebe7d4b56899e2f7e1 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0xc3de830ea07524a0761646a6a4e4be0e114a3c83 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/base/0x3792dbdd07e87413247df995e692806aa13d3299 - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/bnb/0x527856315a4bcd2f428ea7fa05ea251f7e96a50a - 2024-07-10T19:43:34.135Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0x292fcdd1b104de5a00250febba9bc6a5092a0076 - 2024-07-10T23:20:47.940Z - 0.8 - - - https://app.uniswap.org/explore/tokens/ethereum/0xd749b369d361396286f8cc28a99dd3425ac05619 - 2024-07-10T23:20:47.940Z + 2024-05-20T17:20:52.753Z 0.8 \ No newline at end of file diff --git a/apps/web/src/assets/images/extensionIllustration.jpg b/apps/web/src/assets/images/extensionIllustration.jpg new file mode 100644 index 00000000000..48f2bc5f806 Binary files /dev/null and b/apps/web/src/assets/images/extensionIllustration.jpg differ diff --git a/apps/web/src/assets/images/extensionIllustration.png b/apps/web/src/assets/images/extensionIllustration.png deleted file mode 100644 index 2c53c063430..00000000000 Binary files a/apps/web/src/assets/images/extensionIllustration.png and /dev/null differ diff --git a/apps/web/src/assets/images/walletIllustration.jpg b/apps/web/src/assets/images/walletIllustration.jpg new file mode 100644 index 00000000000..344aaa8a87d Binary files /dev/null and b/apps/web/src/assets/images/walletIllustration.jpg differ diff --git a/apps/web/src/assets/images/walletIllustration.png b/apps/web/src/assets/images/walletIllustration.png deleted file mode 100644 index d3cbab40423..00000000000 Binary files a/apps/web/src/assets/images/walletIllustration.png and /dev/null differ diff --git a/apps/web/src/components/AccountDetails/AddressDisplay.tsx b/apps/web/src/components/AccountDetails/AddressDisplay.tsx deleted file mode 100644 index 82970fb6775..00000000000 --- a/apps/web/src/components/AccountDetails/AddressDisplay.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import useENSName from 'hooks/useENSName' -import styled from 'lib/styled-components' -import { CopyHelper, EllipsisStyle } from 'theme/components' -import { Flex } from 'ui/src' -import { Unitag } from 'ui/src/components/icons' -import { useUnitagByAddress } from 'uniswap/src/features/unitags/hooks' -import { shortenAddress } from 'utilities/src/addresses' - -const IdentifierText = styled.span` - ${EllipsisStyle} - max-width: 120px; - @media screen and (min-width: 1440px) { - max-width: 180px; - } -` - -export function AddressDisplay({ address, enableCopyAddress }: { address: Address; enableCopyAddress?: boolean }) { - const { ENSName } = useENSName(address) - const { unitag } = useUnitagByAddress(address) - const uniswapUsername = unitag?.username - - const AddressDisplay = ( - - {uniswapUsername ?? ENSName ?? shortenAddress(address)} - {uniswapUsername && } - - ) - - if (!enableCopyAddress) { - return AddressDisplay - } - - return ( - - {AddressDisplay} - - ) -} diff --git a/apps/web/src/components/AccountDrawer/ActionTile.tsx b/apps/web/src/components/AccountDrawer/ActionTile.tsx index 5df06d3915f..1d8ca7c9269 100644 --- a/apps/web/src/components/AccountDrawer/ActionTile.tsx +++ b/apps/web/src/components/AccountDrawer/ActionTile.tsx @@ -2,10 +2,10 @@ import { ButtonEmphasis, ButtonSize, LoadingButtonSpinner, ThemeButton } from 'c import Column from 'components/Column' import Row from 'components/Row' import Tooltip from 'components/Tooltip' -import styled from 'lib/styled-components' import { ReactNode, useReducer } from 'react' import { Info } from 'react-feather' import { Text } from 'rebass' +import styled from 'styled-components' import { ExternalLink } from 'theme/components' import { ThemedText } from 'theme/components/text' import { uniswapUrls } from 'uniswap/src/constants/urls' diff --git a/apps/web/src/components/AccountDrawer/AuthenticatedHeader.tsx b/apps/web/src/components/AccountDrawer/AuthenticatedHeader.tsx index 1210e9e0875..3fd5cf43775 100644 --- a/apps/web/src/components/AccountDrawer/AuthenticatedHeader.tsx +++ b/apps/web/src/components/AccountDrawer/AuthenticatedHeader.tsx @@ -4,7 +4,6 @@ import { ActionTile } from 'components/AccountDrawer/ActionTile' import IconButton, { IconHoverText, IconWithConfirmTextButton } from 'components/AccountDrawer/IconButton' import MiniPortfolio from 'components/AccountDrawer/MiniPortfolio' import { EmptyWallet } from 'components/AccountDrawer/MiniPortfolio/EmptyWallet' -import { ExtensionDeeplinks } from 'components/AccountDrawer/MiniPortfolio/ExtensionDeeplinks' import { portfolioFadeInAnimation } from 'components/AccountDrawer/MiniPortfolio/PortfolioRow' import { useAccountDrawer } from 'components/AccountDrawer/MiniPortfolio/hooks' import { Status } from 'components/AccountDrawer/Status' @@ -20,9 +19,7 @@ import { LoadingBubble } from 'components/Tokens/loading' import { useTokenBalancesQuery } from 'graphql/data/apollo/TokenBalancesProvider' import { useDisableNFTRoutes } from 'hooks/useDisableNFTRoutes' import useENSName from 'hooks/useENSName' -import { useIsUniExtensionAvailable } from 'hooks/useUniswapWalletOptions' import { Trans, t } from 'i18n' -import styled from 'lib/styled-components' import { useProfilePageState, useSellAsset, useWalletCollections } from 'nft/hooks' import { ProfilePageStateType } from 'nft/types' import { useCallback, useState } from 'react' @@ -30,6 +27,7 @@ import { useNavigate } from 'react-router-dom' import { useCloseModal, useFiatOnrampAvailability, useOpenModal, useToggleModal } from 'state/application/hooks' import { ApplicationModal } from 'state/application/reducer' import { useUserHasAvailableClaim, useUserUnclaimedAmount } from 'state/claim/hooks' +import styled from 'styled-components' import { ThemedText } from 'theme/components' import { ArrowDownCircleFilled } from 'ui/src/components/icons' import { FeatureFlags } from 'uniswap/src/features/gating/flags' @@ -41,8 +39,8 @@ import { isPathBlocked } from 'utils/blockedPaths' import { NumberType, useFormatter } from 'utils/formatNumbers' import { useDisconnect } from 'wagmi' -const AuthenticatedHeaderWrapper = styled.div<{ isUniExtensionAvailable?: boolean }>` - padding: ${({ isUniExtensionAvailable }) => (isUniExtensionAvailable ? 16 : 20)}px 16px; +const AuthenticatedHeaderWrapper = styled.div` + padding: 20px 16px; display: flex; flex-direction: column; flex: 1; @@ -104,13 +102,11 @@ export default function AuthenticatedHeader({ account, openSettings }: { account const { ENSName } = useENSName(account) const navigate = useNavigate() const closeModal = useCloseModal() - const openReceiveModal = useOpenModal(ApplicationModal.RECEIVE_CRYPTO) const setSellPageState = useProfilePageState((state) => state.setProfilePageState) const resetSellAssets = useSellAsset((state) => state.reset) const clearCollectionFilters = useWalletCollections((state) => state.clearCollectionFilters) const shouldShowBuyFiatButton = !isPathBlocked('/buy') const { formatNumber, formatDelta } = useFormatter() - const isUniExtensionAvailable = useIsUniExtensionAvailable() const forAggregatorEnabled = useFeatureFlag(FeatureFlags.ForAggregatorWeb) const shouldDisableNFTRoutes = useDisableNFTRoutes() @@ -181,7 +177,7 @@ export default function AuthenticatedHeader({ account, openSettings }: { account const amount = unclaimedAmount?.toFixed(0, { groupSeparator: ',' } ?? '-') return ( - + @@ -232,52 +228,46 @@ export default function AuthenticatedHeader({ account, openSettings }: { account )} - {isUniExtensionAvailable ? ( - + + {shouldShowBuyFiatButton && ( + } + name={t('common.buy.label')} + onClick={handleBuyCryptoClick} + disabled={disableBuyCryptoButton} + loading={fiatOnrampAvailabilityLoading} + error={Boolean(!fiatOnrampAvailable && fiatOnrampAvailabilityChecked)} + errorMessage={t('common.restricted.region')} + errorTooltip={t('moonpay.restricted.region')} + /> + )} + {!shouldDisableNFTRoutes && !forAggregatorEnabled && ( + } + name={t('nft.view')} + onClick={navigateToProfile} + /> + )} + {forAggregatorEnabled && ( + } + name={t('common.receive')} + onClick={() => undefined} // TODO: implement when recieve modal is implemented + /> + )} + + {isEmptyWallet ? ( + undefined} /> ) : ( - <> - - {shouldShowBuyFiatButton && ( - } - name={t('common.buy.label')} - onClick={handleBuyCryptoClick} - disabled={disableBuyCryptoButton} - loading={fiatOnrampAvailabilityLoading} - error={Boolean(!fiatOnrampAvailable && fiatOnrampAvailabilityChecked)} - errorMessage={t('common.restricted.region')} - errorTooltip={t('moonpay.restricted.region')} - /> - )} - {!shouldDisableNFTRoutes && !forAggregatorEnabled && ( - } - name={t('nft.view')} - onClick={navigateToProfile} - /> - )} - {forAggregatorEnabled && ( - } - name={t('common.receive')} - onClick={openReceiveModal} - /> - )} - - {isEmptyWallet ? ( - - ) : ( - - )} - {isUnclaimed && ( - - - - )} - + + )} + {isUnclaimed && ( + + + )} diff --git a/apps/web/src/components/AccountDrawer/DefaultMenu.tsx b/apps/web/src/components/AccountDrawer/DefaultMenu.tsx index f3d925d21d4..7e6388835f0 100644 --- a/apps/web/src/components/AccountDrawer/DefaultMenu.tsx +++ b/apps/web/src/components/AccountDrawer/DefaultMenu.tsx @@ -2,14 +2,13 @@ import AuthenticatedHeader from 'components/AccountDrawer/AuthenticatedHeader' import LanguageMenu from 'components/AccountDrawer/LanguageMenu' import LocalCurrencyMenu from 'components/AccountDrawer/LocalCurrencyMenu' import { LimitsMenu } from 'components/AccountDrawer/MiniPortfolio/Limits/LimitsMenu' -import { UniExtensionPoolsMenu } from 'components/AccountDrawer/MiniPortfolio/Pools/UniExtensionPoolsMenu' import SettingsMenu from 'components/AccountDrawer/SettingsMenu' import Column from 'components/Column' import WalletModal from 'components/WalletModal' import { useAccount } from 'hooks/useAccount' import { atom, useAtom } from 'jotai' -import styled from 'lib/styled-components' import { useCallback, useEffect, useMemo } from 'react' +import styled from 'styled-components' import { InterfaceEventNameLocal } from 'uniswap/src/features/telemetry/constants' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' @@ -24,7 +23,6 @@ export enum MenuState { LANGUAGE_SETTINGS = 'language_settings', LOCAL_CURRENCY_SETTINGS = 'local_currency_settings', LIMITS = 'limits', - POOLS = 'pools', } export const miniPortfolioMenuStateAtom = atom(MenuState.DEFAULT) @@ -80,8 +78,6 @@ function DefaultMenu({ drawerOpen }: { drawerOpen: boolean }) { return case MenuState.LIMITS: return account.address ? : null - case MenuState.POOLS: - return account.address ? : null } }, [ account.address, diff --git a/apps/web/src/components/AccountDrawer/DownloadButton.tsx b/apps/web/src/components/AccountDrawer/DownloadButton.tsx index aea0f44b17d..2b0b702e16e 100644 --- a/apps/web/src/components/AccountDrawer/DownloadButton.tsx +++ b/apps/web/src/components/AccountDrawer/DownloadButton.tsx @@ -1,6 +1,6 @@ import { InterfaceElementName } from '@uniswap/analytics-events' -import styled from 'lib/styled-components' import { PropsWithChildren, useCallback } from 'react' +import styled from 'styled-components' import { ClickableStyle } from 'theme/components' import { openDownloadApp } from 'utils/openDownloadApp' diff --git a/apps/web/src/components/AccountDrawer/GitVersionRow.tsx b/apps/web/src/components/AccountDrawer/GitVersionRow.tsx index f19b7936c8f..be76d36c588 100644 --- a/apps/web/src/components/AccountDrawer/GitVersionRow.tsx +++ b/apps/web/src/components/AccountDrawer/GitVersionRow.tsx @@ -1,7 +1,7 @@ import Tooltip from 'components/Tooltip' import useCopyClipboard from 'hooks/useCopyClipboard' import { Trans } from 'i18n' -import styled from 'lib/styled-components' +import styled from 'styled-components' import { ThemedText } from 'theme/components' const Container = styled.div` diff --git a/apps/web/src/components/AccountDrawer/IconButton.tsx b/apps/web/src/components/AccountDrawer/IconButton.tsx index e75b5b79311..6fce4ae3e9b 100644 --- a/apps/web/src/components/AccountDrawer/IconButton.tsx +++ b/apps/web/src/components/AccountDrawer/IconButton.tsx @@ -1,7 +1,7 @@ import Row from 'components/Row' -import styled, { DefaultTheme, css } from 'lib/styled-components' import React, { forwardRef, useCallback, useEffect, useRef, useState } from 'react' import { Icon } from 'react-feather' +import styled, { DefaultTheme, css } from 'styled-components' import { TRANSITION_DURATIONS } from 'theme/styles' import useResizeObserver from 'use-resize-observer' @@ -35,11 +35,10 @@ const IconStyles = css<{ hideHorizontal?: boolean }>` :hover { background-color: ${({ theme }) => theme.surface2}; transition: ${({ - theme: { - transition: { duration, timing }, - }, - }) => `${duration.fast} background-color ${timing.in},`} - ${getWidthTransition}; + theme: { + transition: { duration, timing }, + }, + }) => `${duration.fast} background-color ${timing.in}, ${getWidthTransition}`}; ${IconHoverText} { opacity: 1; diff --git a/apps/web/src/components/AccountDrawer/LocalCurrencyMenu.tsx b/apps/web/src/components/AccountDrawer/LocalCurrencyMenu.tsx index aeb887d83d9..30b1b29fb7b 100644 --- a/apps/web/src/components/AccountDrawer/LocalCurrencyMenu.tsx +++ b/apps/web/src/components/AccountDrawer/LocalCurrencyMenu.tsx @@ -1,11 +1,11 @@ -import { SlideOutMenu } from 'components/AccountDrawer/SlideOutMenu' import { MenuColumn, MenuItem } from 'components/AccountDrawer/shared' -import { SUPPORTED_LOCAL_CURRENCIES, SupportedLocalCurrency, getLocalCurrencyIcon } from 'constants/localCurrencies' +import { SlideOutMenu } from 'components/AccountDrawer/SlideOutMenu' +import { getLocalCurrencyIcon, SUPPORTED_LOCAL_CURRENCIES, SupportedLocalCurrency } from 'constants/localCurrencies' import { useActiveLocalCurrency } from 'hooks/useActiveLocalCurrency' import { useLocalCurrencyLinkProps } from 'hooks/useLocalCurrencyLinkProps' import { Trans } from 'i18n' -import styled from 'lib/styled-components' import { useMemo } from 'react' +import styled from 'styled-components' const StyledLocalCurrencyIcon = styled.div` width: 20px; diff --git a/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/ActivityRow.tsx b/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/ActivityRow.tsx index e73c9e4fb82..70528d71e31 100644 --- a/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/ActivityRow.tsx +++ b/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/ActivityRow.tsx @@ -9,9 +9,9 @@ import AlertTriangleFilled from 'components/Icons/AlertTriangleFilled' import { LoaderV2 } from 'components/Icons/LoadingSpinner' import Row from 'components/Row' import useENSName from 'hooks/useENSName' -import styled from 'lib/styled-components' import { useCallback } from 'react' import { SignatureType } from 'state/signatures/types' +import styled from 'styled-components' import { EllipsisStyle, ThemedText } from 'theme/components' import { TransactionStatus } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' import Trace from 'uniswap/src/features/telemetry/Trace' @@ -49,18 +49,8 @@ function StatusIndicator({ activity: { status, timestamp, offchainOrderDetails } } export function ActivityRow({ activity }: { activity: Activity }) { - const { - chainId, - title, - descriptor, - logos, - otherAccount, - currencies, - hash, - prefixIconSrc, - suffixIconSrc, - offchainOrderDetails, - } = activity + const { chainId, title, descriptor, logos, otherAccount, currencies, hash, prefixIconSrc, offchainOrderDetails } = + activity const openOffchainActivityModal = useOpenOffchainActivityModal() @@ -95,7 +85,6 @@ export function ActivityRow({ activity }: { activity: Activity }) { {prefixIconSrc && } {title} - {suffixIconSrc && } } descriptor={ diff --git a/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/CancelOrdersDialog.tsx b/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/CancelOrdersDialog.tsx index c1c51fb0bac..55e255b6fb5 100644 --- a/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/CancelOrdersDialog.tsx +++ b/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/CancelOrdersDialog.tsx @@ -1,20 +1,20 @@ import { CurrencyAmount } from '@uniswap/sdk-core' import { ConfirmedIcon, LogoContainer, SubmittedIcon } from 'components/AccountDrawer/MiniPortfolio/Activity/Logos' import { useCancelOrdersGasEstimate } from 'components/AccountDrawer/MiniPortfolio/Activity/hooks' +import GetHelp from 'components/Button/GetHelp' import Column from 'components/Column' import { Container, Dialog, DialogButtonType, DialogProps } from 'components/Dialog/Dialog' import { LoaderV3 } from 'components/Icons/LoadingSpinner' import Modal from 'components/Modal' -import { GetHelpHeader } from 'components/Modal/GetHelpHeader' import Row from 'components/Row' import { DetailLineItem } from 'components/swap/DetailLineItem' import { nativeOnChain } from 'constants/tokens' import { useStablecoinValue } from 'hooks/useStablecoinPrice' import { Plural, Trans, t } from 'i18n' -import styled, { useTheme } from 'lib/styled-components' import { Slash } from 'react-feather' import { SignatureType, UniswapXOrderDetails } from 'state/signatures/types' -import { ExternalLink, ThemedText } from 'theme/components' +import styled, { useTheme } from 'styled-components' +import { CloseIcon, ExternalLink, ThemedText } from 'theme/components' import { InterfaceChainId } from 'uniswap/src/types/chains' import { NumberType, useFormatter } from 'utils/formatNumbers' import { ExplorerDataType, getExplorerLink } from 'utils/getExplorerLink' @@ -24,9 +24,6 @@ const GasEstimateContainer = styled(Row)` margin-top: 16px; padding-top: 16px; ` -const ModalHeader = styled(GetHelpHeader)` - padding: 4px 0px; -` export enum CancellationState { NOT_STARTED = 'not_started', @@ -107,7 +104,10 @@ export function CancelOrdersDialog( return ( - + + + + {icon} {title} diff --git a/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/Logos.tsx b/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/Logos.tsx index 9c5a6081336..17159dfb1c3 100644 --- a/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/Logos.tsx +++ b/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/Logos.tsx @@ -1,5 +1,5 @@ import { LoaderV3 } from 'components/Icons/LoadingSpinner' -import styled, { css, useTheme } from 'lib/styled-components' +import styled, { css, useTheme } from 'styled-components' import { FadePresence, FadePresenceAnimationType } from 'theme/components/FadePresence' export const LogoContainer = styled.div` diff --git a/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/OffchainActivityModal.tsx b/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/OffchainActivityModal.tsx index 2ad628133d6..34fc4a6c4b9 100644 --- a/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/OffchainActivityModal.tsx +++ b/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/OffchainActivityModal.tsx @@ -25,11 +25,11 @@ import { useUSDPrice } from 'hooks/useUSDPrice' import { Trans } from 'i18n' import { atom } from 'jotai' import { useAtomValue, useUpdateAtom } from 'jotai/utils' -import styled, { useTheme } from 'lib/styled-components' import { ReactNode, useCallback, useMemo, useState } from 'react' import { ArrowDown, X } from 'react-feather' import { useOrder } from 'state/signatures/hooks' import { SignatureType, UniswapXOrderDetails } from 'state/signatures/types' +import styled, { useTheme } from 'styled-components' import { Divider, ThemedText } from 'theme/components' import { UniswapXOrderStatus } from 'types/uniswapx' import { InterfaceEventNameLocal } from 'uniswap/src/features/telemetry/constants' diff --git a/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/__snapshots__/CancelOrdersDialog.test.tsx.snap b/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/__snapshots__/CancelOrdersDialog.test.tsx.snap index b5ea2769a18..26fb9013d9e 100644 --- a/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/__snapshots__/CancelOrdersDialog.test.tsx.snap +++ b/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/__snapshots__/CancelOrdersDialog.test.tsx.snap @@ -1,80 +1,40 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`CancelOrdersDialog should render limit order text 1`] = ` -.c2 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-direction: column; - -ms-flex-direction: column; - flex-direction: column; - -webkit-box-pack: start; - -webkit-justify-content: flex-start; - -ms-flex-pack: start; - justify-content: flex-start; - gap: 24px; -} - -.c11 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-direction: column; - -ms-flex-direction: column; - flex-direction: column; - -webkit-box-pack: start; - -webkit-justify-content: flex-start; - -ms-flex-pack: start; - justify-content: flex-start; - gap: 16px; +.c5 { + box-sizing: border-box; + margin: 0; + min-width: 0; + width: 100%; + padding: 4px 0px; } -.c13 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-direction: column; - -ms-flex-direction: column; - flex-direction: column; - -webkit-box-pack: start; - -webkit-justify-content: flex-start; - -ms-flex-pack: start; - justify-content: flex-start; - gap: 8px; +.c9 { + box-sizing: border-box; + margin: 0; + min-width: 0; } -.c18 { +.c6 { + width: 100%; display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; display: flex; - -webkit-flex-direction: column; - -ms-flex-direction: column; - flex-direction: column; - -webkit-box-pack: start; - -webkit-justify-content: flex-start; - -ms-flex-pack: start; - justify-content: flex-start; -} - -.c3 { - width: 100%; + padding: 0; -webkit-align-items: center; -webkit-box-align: center; -ms-flex-align: center; align-items: center; + -webkit-box-pack: end; + -webkit-justify-content: end; + -ms-flex-pack: end; + justify-content: end; + padding: 4px 0px; + gap: 10px; } -.c8 { - box-sizing: border-box; - margin: 0; - min-width: 0; -} - -.c9 { +.c10 { width: 100%; display: -webkit-box; display: -webkit-flex; @@ -92,7 +52,7 @@ exports[`CancelOrdersDialog should render limit order text 1`] = ` gap: 4px; } -.c19 { +.c20 { width: 100%; display: -webkit-box; display: -webkit-flex; @@ -109,7 +69,7 @@ exports[`CancelOrdersDialog should render limit order text 1`] = ` justify-content: flex-start; } -.c24 { +.c25 { width: 100%; display: -webkit-box; display: -webkit-flex; @@ -127,14 +87,14 @@ exports[`CancelOrdersDialog should render limit order text 1`] = ` gap: 12px; } -.c21 { +.c22 { -webkit-box-pack: justify; -webkit-justify-content: space-between; -ms-flex-pack: justify; justify-content: space-between; } -.c14 { +.c15 { color: #222222; -webkit-letter-spacing: -0.01em; -moz-letter-spacing: -0.01em; @@ -142,7 +102,7 @@ exports[`CancelOrdersDialog should render limit order text 1`] = ` letter-spacing: -0.01em; } -.c16 { +.c17 { color: #7D7D7D; -webkit-letter-spacing: -0.01em; -moz-letter-spacing: -0.01em; @@ -150,7 +110,7 @@ exports[`CancelOrdersDialog should render limit order text 1`] = ` letter-spacing: -0.01em; } -.c10 { +.c11 { color: #222222; cursor: pointer; -webkit-text-decoration: none; @@ -160,15 +120,15 @@ exports[`CancelOrdersDialog should render limit order text 1`] = ` transition-duration: 125ms; } -.c10:hover { +.c11:hover { opacity: 0.6; } -.c10:active { +.c11:active { opacity: 0.4; } -.c6 { +.c7 { -webkit-text-decoration: none; text-decoration: none; cursor: pointer; @@ -179,15 +139,106 @@ exports[`CancelOrdersDialog should render limit order text 1`] = ` font-weight: 500; } -.c6:hover { +.c7:hover { opacity: 0.6; } -.c6:active { +.c7:active { opacity: 0.4; } -.c28 { +.c8 { + width: -webkit-fit-content; + width: -moz-fit-content; + width: fit-content; + border-radius: 16px; + padding: 4px 8px; + font-size: 14px; + font-weight: 485; + line-height: 20px; + background: #F9F9F9; + color: #7D7D7D; + stroke: none; +} + +.c8:hover { + background: #22222212; + color: #222222; + opacity: unset; +} + +.c8:hover path { + fill: #222222; +} + +.c2 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; + -webkit-box-pack: start; + -webkit-justify-content: flex-start; + -ms-flex-pack: start; + justify-content: flex-start; + gap: 24px; +} + +.c12 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; + -webkit-box-pack: start; + -webkit-justify-content: flex-start; + -ms-flex-pack: start; + justify-content: flex-start; + gap: 16px; +} + +.c14 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; + -webkit-box-pack: start; + -webkit-justify-content: flex-start; + -ms-flex-pack: start; + justify-content: flex-start; + gap: 8px; +} + +.c19 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; + -webkit-box-pack: start; + -webkit-justify-content: flex-start; + -ms-flex-pack: start; + justify-content: flex-start; +} + +.c3 { + width: 100%; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; +} + +.c29 { background-color: transparent; bottom: 0; border-radius: inherit; @@ -201,7 +252,7 @@ exports[`CancelOrdersDialog should render limit order text 1`] = ` width: 100%; } -.c25 { +.c26 { -webkit-align-items: center; -webkit-box-align: center; -ms-flex-align: center; @@ -236,30 +287,30 @@ exports[`CancelOrdersDialog should render limit order text 1`] = ` user-select: none; } -.c25:active .c27 { +.c26:active .c28 { background-color: #B8C0DC3d; } -.c25:focus .c27 { +.c26:focus .c28 { background-color: #B8C0DC3d; } -.c25:hover .c27 { +.c26:hover .c28 { background-color: #98A1C014; } -.c25:disabled { +.c26:disabled { cursor: default; opacity: 0.6; } -.c25:disabled:active .c27, -.c25:disabled:focus .c27, -.c25:disabled:hover .c27 { +.c26:disabled:active .c28, +.c26:disabled:focus .c28, +.c26:disabled:hover .c28 { background-color: transparent; } -.c29 { +.c30 { -webkit-align-items: center; -webkit-box-align: center; -ms-flex-align: center; @@ -294,26 +345,26 @@ exports[`CancelOrdersDialog should render limit order text 1`] = ` user-select: none; } -.c29:active .c27 { +.c30:active .c28 { background-color: #B8C0DC3d; } -.c29:focus .c27 { +.c30:focus .c28 { background-color: #B8C0DC3d; } -.c29:hover .c27 { +.c30:hover .c28 { background-color: #98A1C014; } -.c29:disabled { +.c30:disabled { cursor: default; opacity: 0.6; } -.c29:disabled:active .c27, -.c29:disabled:focus .c27, -.c29:disabled:hover .c27 { +.c30:disabled:active .c28, +.c30:disabled:focus .c28, +.c30:disabled:hover .c28 { background-color: transparent; } @@ -360,30 +411,6 @@ exports[`CancelOrdersDialog should render limit order text 1`] = ` border-radius: 20px; } -.c7 { - width: -webkit-fit-content; - width: -moz-fit-content; - width: fit-content; - border-radius: 16px; - padding: 4px 8px; - font-size: 14px; - font-weight: 485; - line-height: 20px; - background: #F9F9F9; - color: #7D7D7D; - stroke: none; -} - -.c7:hover { - background: #22222212; - color: #222222; - opacity: unset; -} - -.c7:hover path { - fill: #222222; -} - .c4 { background-color: #FFFFFF; outline: 1px solid #22222212; @@ -392,7 +419,7 @@ exports[`CancelOrdersDialog should render limit order text 1`] = ` width: 100%; } -.c12 { +.c13 { display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; @@ -411,14 +438,14 @@ exports[`CancelOrdersDialog should render limit order text 1`] = ` border-radius: 12px; } -.c15 { +.c16 { font-size: 24px; line-height: 32px; text-align: center; font-weight: 500; } -.c17 { +.c18 { font-size: 16px; font-weight: 500; line-height: 24px; @@ -429,7 +456,7 @@ exports[`CancelOrdersDialog should render limit order text 1`] = ` text-align: center; } -.c26 { +.c27 { display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; @@ -443,7 +470,7 @@ exports[`CancelOrdersDialog should render limit order text 1`] = ` border-radius: 12px; } -.c30 { +.c31 { display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; @@ -457,21 +484,17 @@ exports[`CancelOrdersDialog should render limit order text 1`] = ` border-radius: 12px; } -.c5 { - padding: 4px 0px; -} - -.c22 { +.c23 { cursor: auto; color: #7D7D7D; } -.c23 { +.c24 { text-align: right; overflow-wrap: break-word; } -.c20 { +.c21 { border-top: 1px solid #22222212; margin-top: 16px; padding-top: 16px; @@ -547,71 +570,68 @@ exports[`CancelOrdersDialog should render limit order text 1`] = ` class="c2 c3 c4" >

+ + + + +
- + + + Get help +
Cancel limit
Your swap could execute before cancellation is processed. Your network costs cannot be refunded. Do you wish to proceed?
Network cost
- @@ -678,21 +698,21 @@ exports[`CancelOrdersDialog should render limit order text 1`] = `
@@ -713,80 +733,40 @@ exports[`CancelOrdersDialog should render limit order text 1`] = ` `; exports[`CancelOrdersDialog should render order cancel correctly 1`] = ` -.c2 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-direction: column; - -ms-flex-direction: column; - flex-direction: column; - -webkit-box-pack: start; - -webkit-justify-content: flex-start; - -ms-flex-pack: start; - justify-content: flex-start; - gap: 24px; -} - -.c11 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-direction: column; - -ms-flex-direction: column; - flex-direction: column; - -webkit-box-pack: start; - -webkit-justify-content: flex-start; - -ms-flex-pack: start; - justify-content: flex-start; - gap: 16px; +.c5 { + box-sizing: border-box; + margin: 0; + min-width: 0; + width: 100%; + padding: 4px 0px; } -.c13 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-direction: column; - -ms-flex-direction: column; - flex-direction: column; - -webkit-box-pack: start; - -webkit-justify-content: flex-start; - -ms-flex-pack: start; - justify-content: flex-start; - gap: 8px; +.c9 { + box-sizing: border-box; + margin: 0; + min-width: 0; } -.c18 { +.c6 { + width: 100%; display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; display: flex; - -webkit-flex-direction: column; - -ms-flex-direction: column; - flex-direction: column; - -webkit-box-pack: start; - -webkit-justify-content: flex-start; - -ms-flex-pack: start; - justify-content: flex-start; -} - -.c3 { - width: 100%; + padding: 0; -webkit-align-items: center; -webkit-box-align: center; -ms-flex-align: center; align-items: center; + -webkit-box-pack: end; + -webkit-justify-content: end; + -ms-flex-pack: end; + justify-content: end; + padding: 4px 0px; + gap: 10px; } -.c8 { - box-sizing: border-box; - margin: 0; - min-width: 0; -} - -.c9 { +.c10 { width: 100%; display: -webkit-box; display: -webkit-flex; @@ -804,7 +784,7 @@ exports[`CancelOrdersDialog should render order cancel correctly 1`] = ` gap: 4px; } -.c19 { +.c20 { width: 100%; display: -webkit-box; display: -webkit-flex; @@ -821,7 +801,7 @@ exports[`CancelOrdersDialog should render order cancel correctly 1`] = ` justify-content: flex-start; } -.c24 { +.c25 { width: 100%; display: -webkit-box; display: -webkit-flex; @@ -839,14 +819,14 @@ exports[`CancelOrdersDialog should render order cancel correctly 1`] = ` gap: 12px; } -.c21 { +.c22 { -webkit-box-pack: justify; -webkit-justify-content: space-between; -ms-flex-pack: justify; justify-content: space-between; } -.c14 { +.c15 { color: #222222; -webkit-letter-spacing: -0.01em; -moz-letter-spacing: -0.01em; @@ -854,7 +834,7 @@ exports[`CancelOrdersDialog should render order cancel correctly 1`] = ` letter-spacing: -0.01em; } -.c16 { +.c17 { color: #7D7D7D; -webkit-letter-spacing: -0.01em; -moz-letter-spacing: -0.01em; @@ -862,7 +842,7 @@ exports[`CancelOrdersDialog should render order cancel correctly 1`] = ` letter-spacing: -0.01em; } -.c10 { +.c11 { color: #222222; cursor: pointer; -webkit-text-decoration: none; @@ -872,15 +852,15 @@ exports[`CancelOrdersDialog should render order cancel correctly 1`] = ` transition-duration: 125ms; } -.c10:hover { +.c11:hover { opacity: 0.6; } -.c10:active { +.c11:active { opacity: 0.4; } -.c6 { +.c7 { -webkit-text-decoration: none; text-decoration: none; cursor: pointer; @@ -891,15 +871,106 @@ exports[`CancelOrdersDialog should render order cancel correctly 1`] = ` font-weight: 500; } -.c6:hover { +.c7:hover { opacity: 0.6; } -.c6:active { +.c7:active { opacity: 0.4; } -.c28 { +.c8 { + width: -webkit-fit-content; + width: -moz-fit-content; + width: fit-content; + border-radius: 16px; + padding: 4px 8px; + font-size: 14px; + font-weight: 485; + line-height: 20px; + background: #F9F9F9; + color: #7D7D7D; + stroke: none; +} + +.c8:hover { + background: #22222212; + color: #222222; + opacity: unset; +} + +.c8:hover path { + fill: #222222; +} + +.c2 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; + -webkit-box-pack: start; + -webkit-justify-content: flex-start; + -ms-flex-pack: start; + justify-content: flex-start; + gap: 24px; +} + +.c12 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; + -webkit-box-pack: start; + -webkit-justify-content: flex-start; + -ms-flex-pack: start; + justify-content: flex-start; + gap: 16px; +} + +.c14 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; + -webkit-box-pack: start; + -webkit-justify-content: flex-start; + -ms-flex-pack: start; + justify-content: flex-start; + gap: 8px; +} + +.c19 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; + -webkit-box-pack: start; + -webkit-justify-content: flex-start; + -ms-flex-pack: start; + justify-content: flex-start; +} + +.c3 { + width: 100%; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; +} + +.c29 { background-color: transparent; bottom: 0; border-radius: inherit; @@ -913,7 +984,7 @@ exports[`CancelOrdersDialog should render order cancel correctly 1`] = ` width: 100%; } -.c25 { +.c26 { -webkit-align-items: center; -webkit-box-align: center; -ms-flex-align: center; @@ -948,30 +1019,30 @@ exports[`CancelOrdersDialog should render order cancel correctly 1`] = ` user-select: none; } -.c25:active .c27 { +.c26:active .c28 { background-color: #B8C0DC3d; } -.c25:focus .c27 { +.c26:focus .c28 { background-color: #B8C0DC3d; } -.c25:hover .c27 { +.c26:hover .c28 { background-color: #98A1C014; } -.c25:disabled { +.c26:disabled { cursor: default; opacity: 0.6; } -.c25:disabled:active .c27, -.c25:disabled:focus .c27, -.c25:disabled:hover .c27 { +.c26:disabled:active .c28, +.c26:disabled:focus .c28, +.c26:disabled:hover .c28 { background-color: transparent; } -.c29 { +.c30 { -webkit-align-items: center; -webkit-box-align: center; -ms-flex-align: center; @@ -1006,26 +1077,26 @@ exports[`CancelOrdersDialog should render order cancel correctly 1`] = ` user-select: none; } -.c29:active .c27 { +.c30:active .c28 { background-color: #B8C0DC3d; } -.c29:focus .c27 { +.c30:focus .c28 { background-color: #B8C0DC3d; } -.c29:hover .c27 { +.c30:hover .c28 { background-color: #98A1C014; } -.c29:disabled { +.c30:disabled { cursor: default; opacity: 0.6; } -.c29:disabled:active .c27, -.c29:disabled:focus .c27, -.c29:disabled:hover .c27 { +.c30:disabled:active .c28, +.c30:disabled:focus .c28, +.c30:disabled:hover .c28 { background-color: transparent; } @@ -1072,30 +1143,6 @@ exports[`CancelOrdersDialog should render order cancel correctly 1`] = ` border-radius: 20px; } -.c7 { - width: -webkit-fit-content; - width: -moz-fit-content; - width: fit-content; - border-radius: 16px; - padding: 4px 8px; - font-size: 14px; - font-weight: 485; - line-height: 20px; - background: #F9F9F9; - color: #7D7D7D; - stroke: none; -} - -.c7:hover { - background: #22222212; - color: #222222; - opacity: unset; -} - -.c7:hover path { - fill: #222222; -} - .c4 { background-color: #FFFFFF; outline: 1px solid #22222212; @@ -1104,7 +1151,7 @@ exports[`CancelOrdersDialog should render order cancel correctly 1`] = ` width: 100%; } -.c12 { +.c13 { display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; @@ -1123,14 +1170,14 @@ exports[`CancelOrdersDialog should render order cancel correctly 1`] = ` border-radius: 12px; } -.c15 { +.c16 { font-size: 24px; line-height: 32px; text-align: center; font-weight: 500; } -.c17 { +.c18 { font-size: 16px; font-weight: 500; line-height: 24px; @@ -1141,7 +1188,7 @@ exports[`CancelOrdersDialog should render order cancel correctly 1`] = ` text-align: center; } -.c26 { +.c27 { display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; @@ -1155,7 +1202,7 @@ exports[`CancelOrdersDialog should render order cancel correctly 1`] = ` border-radius: 12px; } -.c30 { +.c31 { display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; @@ -1169,21 +1216,17 @@ exports[`CancelOrdersDialog should render order cancel correctly 1`] = ` border-radius: 12px; } -.c5 { - padding: 4px 0px; -} - -.c22 { +.c23 { cursor: auto; color: #7D7D7D; } -.c23 { +.c24 { text-align: right; overflow-wrap: break-word; } -.c20 { +.c21 { border-top: 1px solid #22222212; margin-top: 16px; padding-top: 16px; @@ -1259,71 +1302,68 @@ exports[`CancelOrdersDialog should render order cancel correctly 1`] = ` class="c2 c3 c4" >
- + + + Get help +
+ + + + +
Cancel order
Your swap could execute before cancellation is processed. Your network costs cannot be refunded. Do you wish to proceed?
Network cost
- @@ -1390,21 +1430,21 @@ exports[`CancelOrdersDialog should render order cancel correctly 1`] = `
diff --git a/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/__snapshots__/OffchainActivityModal.test.tsx.snap b/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/__snapshots__/OffchainActivityModal.test.tsx.snap index 3378ff6b8ec..efa9a0d17f5 100644 --- a/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/__snapshots__/OffchainActivityModal.test.tsx.snap +++ b/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/__snapshots__/OffchainActivityModal.test.tsx.snap @@ -1,65 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`OrderContent should render without error, filled order 1`] = ` -.c0 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-direction: column; - -ms-flex-direction: column; - flex-direction: column; - -webkit-box-pack: start; - -webkit-justify-content: flex-start; - -ms-flex-pack: start; - justify-content: flex-start; -} - -.c10 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-direction: column; - -ms-flex-direction: column; - flex-direction: column; - -webkit-box-pack: start; - -webkit-justify-content: flex-start; - -ms-flex-pack: start; - justify-content: flex-start; - gap: 12px; -} - -.c12 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-direction: column; - -ms-flex-direction: column; - flex-direction: column; - -webkit-box-pack: start; - -webkit-justify-content: flex-start; - -ms-flex-pack: start; - justify-content: flex-start; - gap: 4px; -} - -.c17 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-direction: column; - -ms-flex-direction: column; - flex-direction: column; - -webkit-box-pack: start; - -webkit-justify-content: flex-start; - -ms-flex-pack: start; - justify-content: flex-start; - gap: 8px; -} - .c1 { box-sizing: border-box; margin: 0; @@ -176,6 +117,65 @@ exports[`OrderContent should render without error, filled order 1`] = ` background-color: #22222212; } +.c0 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; + -webkit-box-pack: start; + -webkit-justify-content: flex-start; + -ms-flex-pack: start; + justify-content: flex-start; +} + +.c10 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; + -webkit-box-pack: start; + -webkit-justify-content: flex-start; + -ms-flex-pack: start; + justify-content: flex-start; + gap: 12px; +} + +.c12 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; + -webkit-box-pack: start; + -webkit-justify-content: flex-start; + -ms-flex-pack: start; + justify-content: flex-start; + gap: 4px; +} + +.c17 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; + -webkit-box-pack: start; + -webkit-justify-content: flex-start; + -ms-flex-pack: start; + justify-content: flex-start; + gap: 8px; +} + .c20 { cursor: auto; color: #7D7D7D; @@ -543,65 +543,6 @@ exports[`OrderContent should render without error, filled order 1`] = ` `; exports[`OrderContent should render without error, limit order 1`] = ` -.c0 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-direction: column; - -ms-flex-direction: column; - flex-direction: column; - -webkit-box-pack: start; - -webkit-justify-content: flex-start; - -ms-flex-pack: start; - justify-content: flex-start; -} - -.c10 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-direction: column; - -ms-flex-direction: column; - flex-direction: column; - -webkit-box-pack: start; - -webkit-justify-content: flex-start; - -ms-flex-pack: start; - justify-content: flex-start; - gap: 12px; -} - -.c12 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-direction: column; - -ms-flex-direction: column; - flex-direction: column; - -webkit-box-pack: start; - -webkit-justify-content: flex-start; - -ms-flex-pack: start; - justify-content: flex-start; - gap: 4px; -} - -.c17 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-direction: column; - -ms-flex-direction: column; - flex-direction: column; - -webkit-box-pack: start; - -webkit-justify-content: flex-start; - -ms-flex-pack: start; - justify-content: flex-start; - gap: 8px; -} - .c1 { box-sizing: border-box; margin: 0; @@ -718,6 +659,65 @@ exports[`OrderContent should render without error, limit order 1`] = ` background-color: #22222212; } +.c0 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; + -webkit-box-pack: start; + -webkit-justify-content: flex-start; + -ms-flex-pack: start; + justify-content: flex-start; +} + +.c10 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; + -webkit-box-pack: start; + -webkit-justify-content: flex-start; + -ms-flex-pack: start; + justify-content: flex-start; + gap: 12px; +} + +.c12 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; + -webkit-box-pack: start; + -webkit-justify-content: flex-start; + -ms-flex-pack: start; + justify-content: flex-start; + gap: 4px; +} + +.c17 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; + -webkit-box-pack: start; + -webkit-justify-content: flex-start; + -ms-flex-pack: start; + justify-content: flex-start; + gap: 8px; +} + .c26 { background-color: transparent; bottom: 0; @@ -1201,65 +1201,6 @@ exports[`OrderContent should render without error, limit order 1`] = ` `; exports[`OrderContent should render without error, open order 1`] = ` -.c0 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-direction: column; - -ms-flex-direction: column; - flex-direction: column; - -webkit-box-pack: start; - -webkit-justify-content: flex-start; - -ms-flex-pack: start; - justify-content: flex-start; -} - -.c10 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-direction: column; - -ms-flex-direction: column; - flex-direction: column; - -webkit-box-pack: start; - -webkit-justify-content: flex-start; - -ms-flex-pack: start; - justify-content: flex-start; - gap: 12px; -} - -.c12 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-direction: column; - -ms-flex-direction: column; - flex-direction: column; - -webkit-box-pack: start; - -webkit-justify-content: flex-start; - -ms-flex-pack: start; - justify-content: flex-start; - gap: 4px; -} - -.c17 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-direction: column; - -ms-flex-direction: column; - flex-direction: column; - -webkit-box-pack: start; - -webkit-justify-content: flex-start; - -ms-flex-pack: start; - justify-content: flex-start; - gap: 8px; -} - .c1 { box-sizing: border-box; margin: 0; @@ -1357,6 +1298,65 @@ exports[`OrderContent should render without error, open order 1`] = ` background-color: #22222212; } +.c0 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; + -webkit-box-pack: start; + -webkit-justify-content: flex-start; + -ms-flex-pack: start; + justify-content: flex-start; +} + +.c10 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; + -webkit-box-pack: start; + -webkit-justify-content: flex-start; + -ms-flex-pack: start; + justify-content: flex-start; + gap: 12px; +} + +.c12 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; + -webkit-box-pack: start; + -webkit-justify-content: flex-start; + -ms-flex-pack: start; + justify-content: flex-start; + gap: 4px; +} + +.c17 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; + -webkit-box-pack: start; + -webkit-justify-content: flex-start; + -ms-flex-pack: start; + justify-content: flex-start; + gap: 8px; +} + .c26 { background-color: transparent; bottom: 0; diff --git a/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/index.tsx b/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/index.tsx index f1ddf89f468..cdedd6901fb 100644 --- a/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/index.tsx +++ b/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/index.tsx @@ -9,9 +9,9 @@ import { hideSpamAtom } from 'components/AccountDrawer/SpamToggle' import Column from 'components/Column' import { LoadingBubble } from 'components/Tokens/loading' import { useAtomValue, useUpdateAtom } from 'jotai/utils' -import styled from 'lib/styled-components' import { EmptyWalletModule } from 'nft/components/profile/view/EmptyWalletContent' import { useMemo } from 'react' +import styled from 'styled-components' import { ThemedText } from 'theme/components' const ActivityGroupWrapper = styled(Column)` diff --git a/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/parseLocal.test.ts b/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/parseLocal.test.ts index 963943ded32..3f9a04f0117 100644 --- a/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/parseLocal.test.ts +++ b/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/parseLocal.test.ts @@ -29,7 +29,7 @@ function mockSwapInfo( inputCurrency: Token, inputCurrencyAmountRaw: string, outputCurrency: Token, - outputCurrencyAmountRaw: string, + outputCurrencyAmountRaw: string ): ExactInputSwapTransactionInfo | ExactOutputSwapTransactionInfo { if (type === MockTradeType.EXACT_INPUT) { return { @@ -109,7 +109,7 @@ jest.mock('../../../../state/transactions/hooks', () => { MockUSDC_MAINNET, mockCurrencyAmountRawUSDC, MockDAI, - mockCurrencyAmountRaw, + mockCurrencyAmountRaw ), ...mockCommonFields('0x123', mockAccount1, TransactionStatus.Confirmed), } as TransactionDetails, @@ -121,9 +121,9 @@ jest.mock('../../../../state/transactions/hooks', () => { MockUSDC_MAINNET, mockCurrencyAmountRawUSDC, MockDAI, - mockCurrencyAmountRaw, + mockCurrencyAmountRaw ), - '0xswap_exact_input', + '0xswap_exact_input' ), ...mockMultiStatus( mockSwapInfo( @@ -131,9 +131,9 @@ jest.mock('../../../../state/transactions/hooks', () => { MockUSDC_MAINNET, mockCurrencyAmountRawUSDC, MockDAI, - mockCurrencyAmountRaw, + mockCurrencyAmountRaw ), - '0xswap_exact_output', + '0xswap_exact_output' ), ...mockMultiStatus( { @@ -142,7 +142,7 @@ jest.mock('../../../../state/transactions/hooks', () => { spender: mockSpenderAddress, amount: mockApprovalAmountRaw, }, - '0xapproval', + '0xapproval' ), ...mockMultiStatus( { @@ -151,7 +151,7 @@ jest.mock('../../../../state/transactions/hooks', () => { spender: mockSpenderAddress, amount: '0', }, - '0xrevoke_approval', + '0xrevoke_approval' ), ...mockMultiStatus( { @@ -160,7 +160,7 @@ jest.mock('../../../../state/transactions/hooks', () => { currencyAmountRaw: mockCurrencyAmountRaw, chainId: mockChainId, }, - '0xwrap', + '0xwrap' ), ...mockMultiStatus( { @@ -169,7 +169,7 @@ jest.mock('../../../../state/transactions/hooks', () => { currencyAmountRaw: mockCurrencyAmountRaw, chainId: mockChainId, }, - '0xunwrap', + '0xunwrap' ), ...mockMultiStatus( { @@ -181,7 +181,7 @@ jest.mock('../../../../state/transactions/hooks', () => { expectedAmountBaseRaw: mockCurrencyAmountRawUSDC, expectedAmountQuoteRaw: mockCurrencyAmountRaw, }, - '0xadd_liquidity_v3', + '0xadd_liquidity_v3' ), ...mockMultiStatus( { @@ -191,7 +191,7 @@ jest.mock('../../../../state/transactions/hooks', () => { expectedAmountBaseRaw: mockCurrencyAmountRawUSDC, expectedAmountQuoteRaw: mockCurrencyAmountRaw, }, - '0xremove_liquidity_v3', + '0xremove_liquidity_v3' ), ...mockMultiStatus( { @@ -201,7 +201,7 @@ jest.mock('../../../../state/transactions/hooks', () => { expectedAmountBaseRaw: mockCurrencyAmountRawUSDC, expectedAmountQuoteRaw: mockCurrencyAmountRaw, }, - '0xadd_liquidity_v2', + '0xadd_liquidity_v2' ), ...mockMultiStatus( { @@ -211,7 +211,7 @@ jest.mock('../../../../state/transactions/hooks', () => { expectedCurrencyOwed0: mockCurrencyAmountRawUSDC, expectedCurrencyOwed1: mockCurrencyAmountRaw, }, - '0xcollect_fees', + '0xcollect_fees' ), ...mockMultiStatus( { @@ -220,7 +220,7 @@ jest.mock('../../../../state/transactions/hooks', () => { quoteCurrencyId: MockDAI.address, isFork: false, }, - '0xmigrate_v3_liquidity', + '0xmigrate_v3_liquidity' ), ] }, @@ -237,7 +237,7 @@ describe('parseLocalActivity', () => { MockUSDC_MAINNET, mockCurrencyAmountRawUSDC, MockDAI, - mockCurrencyAmountRaw, + mockCurrencyAmountRaw ), hash: '0x123', status: TransactionStatus.Confirmed, @@ -265,7 +265,7 @@ describe('parseLocalActivity', () => { MockUSDC_MAINNET, mockCurrencyAmountRawUSDC, MockDAI, - mockCurrencyAmountRaw, + mockCurrencyAmountRaw ), hash: '0x123', status: TransactionStatus.Confirmed, @@ -292,7 +292,7 @@ describe('parseLocalActivity', () => { MockUSDC_MAINNET, mockCurrencyAmountRawUSDC, MockDAI, - mockCurrencyAmountRaw, + mockCurrencyAmountRaw ), hash: '0x123', status: TransactionStatus.Confirmed, @@ -575,8 +575,8 @@ describe('parseLocalActivity', () => { type: SignatureType.SIGN_UNISWAPX_ORDER, status: UniswapXOrderStatus.FILLED, } as SignatureDetails, - formatNumber, - ), + formatNumber + ) ).toBeUndefined() }) @@ -594,11 +594,11 @@ describe('parseLocalActivity', () => { MockUSDC_MAINNET, mockCurrencyAmountRawUSDC, MockDAI, - mockCurrencyAmountRaw, + mockCurrencyAmountRaw ), } as SignatureDetails, - formatNumber, - ), + formatNumber + ) ).toEqual({ chainId: 1, currencies: [MockUSDC_MAINNET, MockDAI], diff --git a/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/parseRemote.tsx b/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/parseRemote.tsx index 1f0a8c0e7ac..37baf7f18d8 100644 --- a/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/parseRemote.tsx +++ b/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/parseRemote.tsx @@ -24,8 +24,6 @@ import { NftApprovalPartsFragment, NftApproveForAllPartsFragment, NftTransferPartsFragment, - OnRampTransactionDetailsPartsFragment, - OnRampTransferPartsFragment, TokenApprovalPartsFragment, TokenAssetPartsFragment, TokenTransferPartsFragment, @@ -358,7 +356,6 @@ function parseLPTransfers(changes: TransactionChanges, formatNumberOrString: For } type TransactionActivity = AssetActivityPartsFragment & { details: TransactionDetailsPartsFragment } -type FiatOnRampActivity = AssetActivityPartsFragment & { details: OnRampTransactionDetailsPartsFragment } function parseSendReceive( changes: TransactionChanges, @@ -506,83 +503,6 @@ function parseUniswapXOrder(activity: OrderActivity): Activity | undefined { } } -function parseFiatOnRampTransaction(activity: TransactionActivity | FiatOnRampActivity): Activity { - const chainId = supportedChainIdFromGQLChain(activity.chain) - if (!chainId) { - const error = new Error('Invalid activity from unsupported chain received from GQL') - logger.error(error, { - tags: { - file: 'parseRemote', - function: 'parseRemote', - }, - extra: { activity }, - }) - throw error - } - if (activity.details.__typename === 'OnRampTransactionDetails') { - const onRampTransfer = activity.details.onRampTransfer - return { - from: activity.details.receiverAddress, - hash: activity.id, - chainId, - timestamp: activity.timestamp, - logos: [onRampTransfer.token.project?.logo?.url], - currencies: [gqlToCurrency(onRampTransfer.token)], - title: t('fiatOnRamp.purchasedOn', { - serviceProvider: onRampTransfer.serviceProvider.name, - }), - descriptor: t('fiatOnRamp.exchangeRate', { - outputAmount: onRampTransfer.amount, - outputSymbol: onRampTransfer.token.symbol, - inputAmount: onRampTransfer.sourceAmount, - inputSymbol: onRampTransfer.sourceCurrency, - }), - suffixIconSrc: onRampTransfer.serviceProvider.logoDarkUrl, - status: activity.details.status, - } - } else if (activity.details.__typename === 'TransactionDetails') { - const assetChange = activity.details.assetChanges[0] - if (assetChange?.__typename !== 'OnRampTransfer') { - logger.error('Unexpected asset change type, expected OnRampTransfer', { - tags: { - file: 'parseRemote', - function: 'parseRemote', - }, - }) - } - const onRampTransfer = assetChange as OnRampTransferPartsFragment - return { - from: activity.details.from, - hash: activity.details.hash, - chainId, - timestamp: activity.timestamp, - logos: [onRampTransfer.token.project?.logo?.url], - currencies: [gqlToCurrency(onRampTransfer.token)], - title: t('fiatOnRamp.purchasedOn', { - serviceProvider: onRampTransfer.serviceProvider.name, - }), - descriptor: t('fiatOnRamp.exchangeRate', { - outputAmount: onRampTransfer.amount, - outputSymbol: onRampTransfer.token.symbol, - inputAmount: onRampTransfer.sourceAmount, - inputSymbol: onRampTransfer.sourceCurrency, - }), - suffixIconSrc: onRampTransfer.serviceProvider.logoDarkUrl, - status: activity.details.status, - } - } else { - const error = new Error('Invalid Fiat On Ramp activity type received from GQL') - logger.error(error, { - tags: { - file: 'parseRemote', - function: 'parseFiatOnRampTransaction', - }, - extra: { activity }, - }) - throw error - } -} - function parseRemoteActivity( assetActivity: AssetActivityPartsFragment | undefined, account: string, @@ -598,12 +518,8 @@ function parseRemoteActivity( return parseUniswapXOrder(assetActivity as OrderActivity) } - if ( - assetActivity.details.__typename === 'OnRampTransactionDetails' || - (assetActivity.details.__typename === 'TransactionDetails' && - assetActivity.details.type === TransactionType.OnRamp) - ) { - return parseFiatOnRampTransaction(assetActivity as TransactionActivity) + if (assetActivity.details.__typename === 'OnRampTransactionDetails') { + return undefined // TODO(WEB-4187): support onramp transactions } const changes = assetActivity.details.assetChanges.reduce( diff --git a/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/types.ts b/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/types.ts index 60d8ec7aa32..1c709ea36a3 100644 --- a/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/types.ts +++ b/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/types.ts @@ -23,7 +23,6 @@ export type Activity = { from: string nonce?: number | null prefixIconSrc?: string - suffixIconSrc?: string cancelled?: boolean isSpam?: boolean } diff --git a/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/utils.test.ts b/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/utils.test.ts index 7183d388e41..e612bb1cfb1 100644 --- a/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/utils.test.ts +++ b/apps/web/src/components/AccountDrawer/MiniPortfolio/Activity/utils.test.ts @@ -23,7 +23,7 @@ describe('createGroups', () => { transactions: expect.arrayContaining([ expect.objectContaining({ timestamp: expect.any(Number), status: TransactionStatus.Confirmed }), ]), - }), + }) ) expect(createGroups(mockActivities, true)).toEqual([]) }) @@ -43,7 +43,7 @@ describe('createGroups', () => { transactions: expect.arrayContaining([ expect.objectContaining({ timestamp: 1700000000, status: TransactionStatus.Pending }), ]), - }), + }) ) expect(result).toContainEqual( @@ -52,7 +52,7 @@ describe('createGroups', () => { transactions: expect.arrayContaining([ expect.objectContaining({ timestamp: expect.any(Number), status: TransactionStatus.Confirmed }), ]), - }), + }) ) }) }) diff --git a/apps/web/src/components/AccountDrawer/MiniPortfolio/EmptyWallet.tsx b/apps/web/src/components/AccountDrawer/MiniPortfolio/EmptyWallet.tsx index 1edf1419eee..9377496bd3c 100644 --- a/apps/web/src/components/AccountDrawer/MiniPortfolio/EmptyWallet.tsx +++ b/apps/web/src/components/AccountDrawer/MiniPortfolio/EmptyWallet.tsx @@ -1,6 +1,6 @@ import { Trans, t } from 'i18n' -import styled from 'lib/styled-components' import { useCallback, useMemo } from 'react' +import styled from 'styled-components' import { Flex, Text, useIsDarkMode } from 'ui/src' import { CRYPTO_PURCHASE_BACKGROUND_DARK, CRYPTO_PURCHASE_BACKGROUND_LIGHT } from 'ui/src/assets' import { ArrowDownCircle, Buy as BuyIcon } from 'ui/src/components/icons' @@ -38,8 +38,8 @@ export const EmptyWallet = ({ BackgroundImageWrapperCallback, }, { - title: t('fiatOnRamp.receiveCrypto.title'), - blurb: t('fiatOnRamp.receiveCrypto.transferFunds'), + title: t('home.tokens.empty.action.receive.title'), + blurb: t('home.tokens.empty.action.receive.description'), elementName: ElementName.EmptyStateReceive, icon: , onPress: handleReceiveCryptoClick, diff --git a/apps/web/src/components/AccountDrawer/MiniPortfolio/ExpandoRow.tsx b/apps/web/src/components/AccountDrawer/MiniPortfolio/ExpandoRow.tsx index 691556bd2b7..4286a0317a2 100644 --- a/apps/web/src/components/AccountDrawer/MiniPortfolio/ExpandoRow.tsx +++ b/apps/web/src/components/AccountDrawer/MiniPortfolio/ExpandoRow.tsx @@ -1,9 +1,9 @@ import Column from 'components/Column' import Row from 'components/Row' import { t } from 'i18n' -import styled from 'lib/styled-components' import { PropsWithChildren } from 'react' import { ChevronDown } from 'react-feather' +import styled from 'styled-components' import { ThemedText } from 'theme/components' const ExpandIcon = styled(ChevronDown)<{ $expanded: boolean }>` diff --git a/apps/web/src/components/AccountDrawer/MiniPortfolio/ExtensionDeeplinks.tsx b/apps/web/src/components/AccountDrawer/MiniPortfolio/ExtensionDeeplinks.tsx deleted file mode 100644 index 192720fc63b..00000000000 --- a/apps/web/src/components/AccountDrawer/MiniPortfolio/ExtensionDeeplinks.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { MenuState, miniPortfolioMenuStateAtom } from 'components/AccountDrawer/DefaultMenu' -import { useOpenLimitOrders, usePendingActivity } from 'components/AccountDrawer/MiniPortfolio/Activity/hooks' -import { useFilterPossiblyMaliciousPositionInfo } from 'components/AccountDrawer/MiniPortfolio/Pools' -import useMultiChainPositions from 'components/AccountDrawer/MiniPortfolio/Pools/useMultiChainPositions' -import { useAccountDrawer } from 'components/AccountDrawer/MiniPortfolio/hooks' -import { Pool } from 'components/Icons/Pool' -import { ExtensionRequestMethods, useUniswapExtensionConnector } from 'components/WalletModal/useOrderedConnections' -import { t } from 'i18n' -import { useUpdateAtom } from 'jotai/utils' -import { useTheme } from 'lib/styled-components' -import { useEffect, useState } from 'react' -import { Button, Flex, Image, Text } from 'ui/src' -import { UNISWAP_LOGO } from 'ui/src/assets' -import { ArrowRightToLine, RotatableChevron, TimePast } from 'ui/src/components/icons' -import { iconSizes } from 'ui/src/theme/iconSizes' - -const UnreadIndicator = () => { - const theme = useTheme() - - return ( - - - - - - ) -} - -const DeepLinkButton = ({ Icon, Label, onPress }: { Icon: JSX.Element; Label: string; onPress: () => void }) => { - return ( - - ) -} - -export function ExtensionDeeplinks({ account }: { account: string }) { - const theme = useTheme() - const uniswapExtensionConnector = useUniswapExtensionConnector() - const accountDrawer = useAccountDrawer() - const setMenu = useUpdateAtom(miniPortfolioMenuStateAtom) - const { openLimitOrders } = useOpenLimitOrders(account) - - const [activityUnread, setActivityUnread] = useState(false) - const { hasPendingActivity } = usePendingActivity() - useEffect(() => { - if (hasPendingActivity) { - setActivityUnread(true) - } - }, [hasPendingActivity]) - - const { positions } = useMultiChainPositions(account) - const filteredPositions = useFilterPossiblyMaliciousPositionInfo(positions) - - if (!uniswapExtensionConnector) { - return null - } - - return ( - - } - Label={t('extension.open')} - onPress={() => { - uniswapExtensionConnector.extensionRequest(ExtensionRequestMethods.OPEN_SIDEBAR, 'Tokens') - accountDrawer.close() - }} - /> - - - {activityUnread && } - - } - Label={t('common.activity')} - onPress={() => { - uniswapExtensionConnector.extensionRequest(ExtensionRequestMethods.OPEN_SIDEBAR, 'Activity') - accountDrawer.close() - setActivityUnread(false) - }} - /> - {filteredPositions.length > 0 && ( - } - Label={t('common.pools')} - onPress={() => setMenu(MenuState.POOLS)} - /> - )} - {openLimitOrders.length > 0 && ( - } - Label={t('common.limits')} - onPress={() => setMenu(MenuState.LIMITS)} - /> - )} - - ) -} diff --git a/apps/web/src/components/AccountDrawer/MiniPortfolio/Limits/LimitDetailActivityRow.tsx b/apps/web/src/components/AccountDrawer/MiniPortfolio/Limits/LimitDetailActivityRow.tsx index 103c2f58e98..39e30ed4093 100644 --- a/apps/web/src/components/AccountDrawer/MiniPortfolio/Limits/LimitDetailActivityRow.tsx +++ b/apps/web/src/components/AccountDrawer/MiniPortfolio/Limits/LimitDetailActivityRow.tsx @@ -12,10 +12,10 @@ import { parseUnits } from 'ethers/lib/utils' import { useCurrencyInfo } from 'hooks/Tokens' import { useScreenSize } from 'hooks/screenSize' import { Trans } from 'i18n' -import styled, { useTheme } from 'lib/styled-components' import { Checkbox } from 'nft/components/layout/Checkbox' import { useMemo, useState } from 'react' import { ArrowRight } from 'react-feather' +import styled, { useTheme } from 'styled-components' import { EllipsisStyle, ThemedText } from 'theme/components' import { UniswapXOrderStatus } from 'types/uniswapx' import { useFormatter } from 'utils/formatNumbers' diff --git a/apps/web/src/components/AccountDrawer/MiniPortfolio/Limits/LimitsMenu.tsx b/apps/web/src/components/AccountDrawer/MiniPortfolio/Limits/LimitsMenu.tsx index 06dbf0679ba..3014f3d8387 100644 --- a/apps/web/src/components/AccountDrawer/MiniPortfolio/Limits/LimitsMenu.tsx +++ b/apps/web/src/components/AccountDrawer/MiniPortfolio/Limits/LimitsMenu.tsx @@ -11,9 +11,9 @@ import { ButtonEmphasis, ButtonSize, ThemeButton } from 'components/Button' import Column from 'components/Column' import { LimitDisclaimer } from 'components/swap/LimitDisclaimer' import { Plural, Trans, t } from 'i18n' -import styled from 'lib/styled-components' import { useMemo, useState } from 'react' import { UniswapXOrderDetails } from 'state/signatures/types' +import styled from 'styled-components' import { UniswapXOrderStatus } from 'types/uniswapx' const Container = styled(Column)` diff --git a/apps/web/src/components/AccountDrawer/MiniPortfolio/Limits/OpenLimitOrdersButton.tsx b/apps/web/src/components/AccountDrawer/MiniPortfolio/Limits/OpenLimitOrdersButton.tsx index 7e36e0aaebe..bb0ee3ae8ef 100644 --- a/apps/web/src/components/AccountDrawer/MiniPortfolio/Limits/OpenLimitOrdersButton.tsx +++ b/apps/web/src/components/AccountDrawer/MiniPortfolio/Limits/OpenLimitOrdersButton.tsx @@ -1,8 +1,8 @@ import { useOpenLimitOrders } from 'components/AccountDrawer/MiniPortfolio/Activity/hooks' import { TabButton } from 'components/AccountDrawer/MiniPortfolio/shared' import { Plural, Trans, t } from 'i18n' -import { useTheme } from 'lib/styled-components' import { Clock } from 'react-feather' +import { useTheme } from 'styled-components' function getExtraWarning(openLimitOrders: any[]) { if (openLimitOrders.length >= 100) { diff --git a/apps/web/src/components/AccountDrawer/MiniPortfolio/Limits/__snapshots__/LimitDetailActivityRow.test.tsx.snap b/apps/web/src/components/AccountDrawer/MiniPortfolio/Limits/__snapshots__/LimitDetailActivityRow.test.tsx.snap index 0d27cc71a9e..d3f174be935 100644 --- a/apps/web/src/components/AccountDrawer/MiniPortfolio/Limits/__snapshots__/LimitDetailActivityRow.test.tsx.snap +++ b/apps/web/src/components/AccountDrawer/MiniPortfolio/Limits/__snapshots__/LimitDetailActivityRow.test.tsx.snap @@ -1,29 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`LimitDetailActivityRow should render with valid details 1`] = ` -.c6 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-direction: column; - -ms-flex-direction: column; - flex-direction: column; - -webkit-box-pack: start; - -webkit-justify-content: flex-start; - -ms-flex-pack: start; - justify-content: flex-start; -} - -.c4 { - display: grid; - grid-auto-rows: auto; - -webkit-box-flex: 1; - -webkit-flex-grow: 1; - -ms-flex-positive: 1; - flex-grow: 1; -} - .c0 { box-sizing: border-box; margin: 0; @@ -88,6 +65,29 @@ exports[`LimitDetailActivityRow should render with valid details 1`] = ` letter-spacing: -0.01em; } +.c6 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; + -webkit-box-pack: start; + -webkit-justify-content: flex-start; + -ms-flex-pack: start; + justify-content: flex-start; +} + +.c4 { + display: grid; + grid-auto-rows: auto; + -webkit-box-flex: 1; + -webkit-flex-grow: 1; + -ms-flex-positive: 1; + flex-grow: 1; +} + .c2 { gap: 12px; height: 68px; diff --git a/apps/web/src/components/AccountDrawer/MiniPortfolio/Limits/__snapshots__/LimitsMenu.test.tsx.snap b/apps/web/src/components/AccountDrawer/MiniPortfolio/Limits/__snapshots__/LimitsMenu.test.tsx.snap index b029f5fc5af..61a187120b3 100644 --- a/apps/web/src/components/AccountDrawer/MiniPortfolio/Limits/__snapshots__/LimitsMenu.test.tsx.snap +++ b/apps/web/src/components/AccountDrawer/MiniPortfolio/Limits/__snapshots__/LimitsMenu.test.tsx.snap @@ -1,44 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`LimitsMenu should render when there are two open orders 1`] = ` -.c0 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-direction: column; - -ms-flex-direction: column; - flex-direction: column; - -webkit-box-pack: start; - -webkit-justify-content: flex-start; - -ms-flex-pack: start; - justify-content: flex-start; -} - -.c6 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-direction: column; - -ms-flex-direction: column; - flex-direction: column; - -webkit-box-pack: start; - -webkit-justify-content: flex-start; - -ms-flex-pack: start; - justify-content: flex-start; - gap: 8px; -} - -.c17 { - display: grid; - grid-auto-rows: auto; - -webkit-box-flex: 1; - -webkit-flex-grow: 1; - -ms-flex-positive: 1; - flex-grow: 1; -} - .c13 { box-sizing: border-box; margin: 0; @@ -122,6 +84,44 @@ exports[`LimitsMenu should render when there are two open orders 1`] = ` opacity: 0.4; } +.c0 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; + -webkit-box-pack: start; + -webkit-justify-content: flex-start; + -ms-flex-pack: start; + justify-content: flex-start; +} + +.c6 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; + -webkit-box-pack: start; + -webkit-justify-content: flex-start; + -ms-flex-pack: start; + justify-content: flex-start; + gap: 8px; +} + +.c17 { + display: grid; + grid-auto-rows: auto; + -webkit-box-flex: 1; + -webkit-flex-grow: 1; + -ms-flex-positive: 1; + flex-grow: 1; +} + .c7 { background-color: #F9F9F9; border-radius: 12px; @@ -588,44 +588,6 @@ exports[`LimitsMenu should render when there are two open orders 1`] = ` `; exports[`LimitsMenu should render when there is one open order 1`] = ` -.c0 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-direction: column; - -ms-flex-direction: column; - flex-direction: column; - -webkit-box-pack: start; - -webkit-justify-content: flex-start; - -ms-flex-pack: start; - justify-content: flex-start; -} - -.c6 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-direction: column; - -ms-flex-direction: column; - flex-direction: column; - -webkit-box-pack: start; - -webkit-justify-content: flex-start; - -ms-flex-pack: start; - justify-content: flex-start; - gap: 8px; -} - -.c17 { - display: grid; - grid-auto-rows: auto; - -webkit-box-flex: 1; - -webkit-flex-grow: 1; - -ms-flex-positive: 1; - flex-grow: 1; -} - .c13 { box-sizing: border-box; margin: 0; @@ -709,6 +671,44 @@ exports[`LimitsMenu should render when there is one open order 1`] = ` opacity: 0.4; } +.c0 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; + -webkit-box-pack: start; + -webkit-justify-content: flex-start; + -ms-flex-pack: start; + justify-content: flex-start; +} + +.c6 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; + -webkit-box-pack: start; + -webkit-justify-content: flex-start; + -ms-flex-pack: start; + justify-content: flex-start; + gap: 8px; +} + +.c17 { + display: grid; + grid-auto-rows: auto; + -webkit-box-flex: 1; + -webkit-flex-grow: 1; + -ms-flex-positive: 1; + flex-grow: 1; +} + .c7 { background-color: #F9F9F9; border-radius: 12px; diff --git a/apps/web/src/components/AccountDrawer/MiniPortfolio/NFTs/NFTItem.tsx b/apps/web/src/components/AccountDrawer/MiniPortfolio/NFTs/NFTItem.tsx index 3787f70b3e8..3e5f5e6bfaf 100644 --- a/apps/web/src/components/AccountDrawer/MiniPortfolio/NFTs/NFTItem.tsx +++ b/apps/web/src/components/AccountDrawer/MiniPortfolio/NFTs/NFTItem.tsx @@ -4,13 +4,13 @@ import Column from 'components/Column' import Row from 'components/Row' import { MouseFollowTooltip, TooltipSize } from 'components/Tooltip' import { t } from 'i18next' -import styled from 'lib/styled-components' import { Box } from 'nft/components/Box' import { NftCard } from 'nft/components/card' import { detailsHref } from 'nft/components/card/utils' import { VerifiedIcon } from 'nft/components/icons' import { WalletAsset } from 'nft/types' import { useNavigate } from 'react-router-dom' +import styled from 'styled-components' import { ThemedText } from 'theme/components' import { capitalize } from 'tsafe' import { Chain } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' diff --git a/apps/web/src/components/AccountDrawer/MiniPortfolio/NFTs/index.tsx b/apps/web/src/components/AccountDrawer/MiniPortfolio/NFTs/index.tsx index e23566da226..deb977c21a9 100644 --- a/apps/web/src/components/AccountDrawer/MiniPortfolio/NFTs/index.tsx +++ b/apps/web/src/components/AccountDrawer/MiniPortfolio/NFTs/index.tsx @@ -4,7 +4,6 @@ import { useAccountDrawer } from 'components/AccountDrawer/MiniPortfolio/hooks' import { TabButton } from 'components/AccountDrawer/MiniPortfolio/shared' import { useNftBalance } from 'graphql/data/nft/NftBalance' import { t } from 'i18n' -import styled from 'lib/styled-components' import { LoadingAssets } from 'nft/components/collection/CollectionAssetLoading' import { EmptyWalletModule } from 'nft/components/profile/view/EmptyWalletContent' import { useProfilePageState, useSellAsset, useWalletCollections } from 'nft/hooks' @@ -12,6 +11,7 @@ import { ProfilePageStateType } from 'nft/types' import { useCallback, useState } from 'react' import InfiniteScroll from 'react-infinite-scroll-component' import { useNavigate } from 'react-router-dom' +import styled from 'styled-components' import { Gallery } from 'ui/src/components/icons' import { Chain } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' import { FeatureFlags } from 'uniswap/src/features/gating/flags' diff --git a/apps/web/src/components/AccountDrawer/MiniPortfolio/Pools/UniExtensionPoolsMenu.tsx b/apps/web/src/components/AccountDrawer/MiniPortfolio/Pools/UniExtensionPoolsMenu.tsx deleted file mode 100644 index 85ff437a5be..00000000000 --- a/apps/web/src/components/AccountDrawer/MiniPortfolio/Pools/UniExtensionPoolsMenu.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import Pools from 'components/AccountDrawer/MiniPortfolio/Pools' -import { SlideOutMenu } from 'components/AccountDrawer/SlideOutMenu' -import Column from 'components/Column' -import { Trans } from 'i18n' -import styled from 'lib/styled-components' - -const Container = styled(Column)` - height: 100%; - position: relative; -` - -export function UniExtensionPoolsMenu({ onClose, account }: { account: string; onClose: () => void }) { - return ( - } onClose={onClose}> - - - - - ) -} diff --git a/apps/web/src/components/AccountDrawer/MiniPortfolio/Pools/hooks.ts b/apps/web/src/components/AccountDrawer/MiniPortfolio/Pools/hooks.ts index e909758c2c8..1ad6803f1db 100644 --- a/apps/web/src/components/AccountDrawer/MiniPortfolio/Pools/hooks.ts +++ b/apps/web/src/components/AccountDrawer/MiniPortfolio/Pools/hooks.ts @@ -98,7 +98,7 @@ export function usePoolPriceMap(positions: PositionInfo[] | undefined) { } function useFeeValue(token: Token, fee: number | undefined, queriedPrice: number | undefined) { - const { price: stablecoinPrice } = useStablecoinPrice(!queriedPrice ? token : undefined) + const stablecoinPrice = useStablecoinPrice(!queriedPrice ? token : undefined) return useMemo(() => { // Prefers gql price, as fetching stablecoinPrice will trigger multiple infura calls for each pool position const price = queriedPrice ?? (stablecoinPrice ? parseFloat(stablecoinPrice.toSignificant()) : undefined) diff --git a/apps/web/src/components/AccountDrawer/MiniPortfolio/Pools/index.tsx b/apps/web/src/components/AccountDrawer/MiniPortfolio/Pools/index.tsx index 93913c03cb7..5edea6ccc1a 100644 --- a/apps/web/src/components/AccountDrawer/MiniPortfolio/Pools/index.tsx +++ b/apps/web/src/components/AccountDrawer/MiniPortfolio/Pools/index.tsx @@ -17,10 +17,10 @@ import { useAccount } from 'hooks/useAccount' import { useFilterPossiblyMaliciousPositions } from 'hooks/useFilterPossiblyMaliciousPositions' import { useSwitchChain } from 'hooks/useSwitchChain' import { t } from 'i18n' -import styled from 'lib/styled-components' import { EmptyWalletModule } from 'nft/components/profile/view/EmptyWalletContent' import { useCallback, useMemo, useReducer } from 'react' import { useNavigate } from 'react-router-dom' +import styled from 'styled-components' import { ThemedText } from 'theme/components' import Trace from 'uniswap/src/features/telemetry/Trace' import { NumberType, useFormatter } from 'utils/formatNumbers' @@ -31,7 +31,7 @@ import { NumberType, useFormatter } from 'utils/formatNumbers' * filters the PositionDetails data for malicious content, * and then returns the original data in its original format. */ -export function useFilterPossiblyMaliciousPositionInfo(positions: PositionInfo[] | undefined): PositionInfo[] { +function useFilterPossiblyMaliciousPositionInfo(positions: PositionInfo[] | undefined): PositionInfo[] { const tokenIdsToPositionInfo: Record = useMemo( () => positions diff --git a/apps/web/src/components/AccountDrawer/MiniPortfolio/Pools/useMultiChainPositions.tsx b/apps/web/src/components/AccountDrawer/MiniPortfolio/Pools/useMultiChainPositions.tsx index a3e35f8aa86..38314513aeb 100644 --- a/apps/web/src/components/AccountDrawer/MiniPortfolio/Pools/useMultiChainPositions.tsx +++ b/apps/web/src/components/AccountDrawer/MiniPortfolio/Pools/useMultiChainPositions.tsx @@ -13,7 +13,7 @@ import { usePoolPriceMap, useV3ManagerContracts, } from 'components/AccountDrawer/MiniPortfolio/Pools/hooks' -import { PRODUCTION_CHAIN_IDS } from 'constants/chains' +import { L1_CHAIN_IDS, L2_CHAIN_IDS, TESTNET_CHAIN_IDS } from 'constants/chains' import { BigNumber } from 'ethers/lib/ethers' import { Interface } from 'ethers/lib/utils' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -51,6 +51,10 @@ type FeeAmounts = [BigNumber, BigNumber] const MAX_UINT128 = BigNumber.from(2).pow(128).sub(1) +const DEFAULT_CHAINS = [...L1_CHAIN_IDS, ...L2_CHAIN_IDS].filter((chain: number) => { + return !TESTNET_CHAIN_IDS.includes(chain) +}) + type UseMultiChainPositionsData = { positions?: PositionInfo[]; loading: boolean } /** @@ -62,10 +66,7 @@ type UseMultiChainPositionsData = { positions?: PositionInfo[]; loading: boolean * @param chains - chains to fetch positions from * @returns positions, fees */ -export default function useMultiChainPositions( - account: string, - chains = PRODUCTION_CHAIN_IDS, -): UseMultiChainPositionsData { +export default function useMultiChainPositions(account: string, chains = DEFAULT_CHAINS): UseMultiChainPositionsData { const pms = useV3ManagerContracts(chains) const multicalls = useInterfaceMulticallContracts(chains) diff --git a/apps/web/src/components/AccountDrawer/MiniPortfolio/PortfolioLogo.tsx b/apps/web/src/components/AccountDrawer/MiniPortfolio/PortfolioLogo.tsx index 4af15148e66..bccd8611878 100644 --- a/apps/web/src/components/AccountDrawer/MiniPortfolio/PortfolioLogo.tsx +++ b/apps/web/src/components/AccountDrawer/MiniPortfolio/PortfolioLogo.tsx @@ -10,8 +10,8 @@ import { } from 'components/DoubleLogo' import Identicon from 'components/Identicon' import { ChainLogo } from 'components/Logo/ChainLogo' -import styled from 'lib/styled-components' import React from 'react' +import styled from 'styled-components' import { InterfaceChainId, UniverseChainId } from 'uniswap/src/types/chains' const UnknownContract = styled(UnknownStatus)` diff --git a/apps/web/src/components/AccountDrawer/MiniPortfolio/PortfolioRow.tsx b/apps/web/src/components/AccountDrawer/MiniPortfolio/PortfolioRow.tsx index 2569e566a31..7e55431cea9 100644 --- a/apps/web/src/components/AccountDrawer/MiniPortfolio/PortfolioRow.tsx +++ b/apps/web/src/components/AccountDrawer/MiniPortfolio/PortfolioRow.tsx @@ -1,7 +1,7 @@ import Column, { AutoColumn } from 'components/Column' import Row from 'components/Row' import { LoadingBubble } from 'components/Tokens/loading' -import styled, { css, keyframes } from 'lib/styled-components' +import styled, { css, keyframes } from 'styled-components' export const PortfolioRowWrapper = styled(Row)<{ onClick?: any }>` gap: 12px; diff --git a/apps/web/src/components/AccountDrawer/MiniPortfolio/Tokens/index.tsx b/apps/web/src/components/AccountDrawer/MiniPortfolio/Tokens/index.tsx index 025b8e38146..73467be9f9e 100644 --- a/apps/web/src/components/AccountDrawer/MiniPortfolio/Tokens/index.tsx +++ b/apps/web/src/components/AccountDrawer/MiniPortfolio/Tokens/index.tsx @@ -14,10 +14,10 @@ import { useTokenBalancesQuery } from 'graphql/data/apollo/TokenBalancesProvider import { PortfolioToken } from 'graphql/data/portfolios' import { getTokenDetailsURL, gqlToCurrency } from 'graphql/data/util' import { useAtomValue } from 'jotai/utils' -import styled from 'lib/styled-components' import { EmptyWalletModule } from 'nft/components/profile/view/EmptyWalletContent' import { useCallback, useMemo, useState } from 'react' import { useNavigate } from 'react-router-dom' +import styled from 'styled-components' import { EllipsisStyle, ThemedText } from 'theme/components' import { PortfolioTokenBalancePartsFragment } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' import Trace from 'uniswap/src/features/telemetry/Trace' diff --git a/apps/web/src/components/AccountDrawer/MiniPortfolio/index.tsx b/apps/web/src/components/AccountDrawer/MiniPortfolio/index.tsx index 4cc631dd607..a93dcd4f03d 100644 --- a/apps/web/src/components/AccountDrawer/MiniPortfolio/index.tsx +++ b/apps/web/src/components/AccountDrawer/MiniPortfolio/index.tsx @@ -12,8 +12,8 @@ import { useDisableNFTRoutes } from 'hooks/useDisableNFTRoutes' import { useIsNftPage } from 'hooks/useIsNftPage' import { Trans } from 'i18n' import { atom, useAtom } from 'jotai' -import styled, { useTheme } from 'lib/styled-components' import { useEffect, useState } from 'react' +import styled, { useTheme } from 'styled-components' import { BREAKPOINTS } from 'theme' import { ThemedText } from 'theme/components' import Trace from 'uniswap/src/features/telemetry/Trace' diff --git a/apps/web/src/components/AccountDrawer/MiniPortfolio/shared.tsx b/apps/web/src/components/AccountDrawer/MiniPortfolio/shared.tsx index 161a71452f9..bd8aaff7e91 100644 --- a/apps/web/src/components/AccountDrawer/MiniPortfolio/shared.tsx +++ b/apps/web/src/components/AccountDrawer/MiniPortfolio/shared.tsx @@ -1,8 +1,8 @@ import Column from 'components/Column' import Row from 'components/Row' -import styled, { useTheme } from 'lib/styled-components' import { ReactNode } from 'react' import { ArrowRight } from 'react-feather' +import styled, { useTheme } from 'styled-components' import { ClickableStyle, ThemedText } from 'theme/components' import { Text } from 'ui/src' diff --git a/apps/web/src/components/AccountDrawer/SettingsMenu.tsx b/apps/web/src/components/AccountDrawer/SettingsMenu.tsx index a2259883571..ab6316f70db 100644 --- a/apps/web/src/components/AccountDrawer/SettingsMenu.tsx +++ b/apps/web/src/components/AccountDrawer/SettingsMenu.tsx @@ -11,9 +11,9 @@ import { LOCALE_LABEL } from 'constants/locales' import { useActiveLocalCurrency } from 'hooks/useActiveLocalCurrency' import { useActiveLocale } from 'hooks/useActiveLocale' import { Trans } from 'i18n' -import styled from 'lib/styled-components' import { ReactNode } from 'react' import { ChevronRight } from 'react-feather' +import styled from 'styled-components' import { ClickableStyle, ThemedText } from 'theme/components' import ThemeToggle from 'theme/components/ThemeToggle' import { FeatureFlags } from 'uniswap/src/features/gating/flags' diff --git a/apps/web/src/components/AccountDrawer/SettingsToggle.tsx b/apps/web/src/components/AccountDrawer/SettingsToggle.tsx index 5d4661d2d07..39117ffdf28 100644 --- a/apps/web/src/components/AccountDrawer/SettingsToggle.tsx +++ b/apps/web/src/components/AccountDrawer/SettingsToggle.tsx @@ -1,8 +1,8 @@ import Column from 'components/Column' import Row from 'components/Row' import Toggle from 'components/Toggle' -import styled from 'lib/styled-components' import { ReactNode } from 'react' +import styled from 'styled-components' import { ThemedText } from 'theme/components' const StyledColumn = styled(Column)` diff --git a/apps/web/src/components/AccountDrawer/SlideOutMenu.tsx b/apps/web/src/components/AccountDrawer/SlideOutMenu.tsx index 2d8d5f040ec..e8ce58bacd2 100644 --- a/apps/web/src/components/AccountDrawer/SlideOutMenu.tsx +++ b/apps/web/src/components/AccountDrawer/SlideOutMenu.tsx @@ -1,7 +1,7 @@ import Column from 'components/Column' import { ScrollBarStyles } from 'components/Common' -import styled from 'lib/styled-components' import { ArrowLeft } from 'react-feather' +import styled from 'styled-components' import { ClickableStyle, ThemedText } from 'theme/components' const Menu = styled(Column)` diff --git a/apps/web/src/components/AccountDrawer/Status.tsx b/apps/web/src/components/AccountDrawer/Status.tsx index bf8eced156e..07d981c554e 100644 --- a/apps/web/src/components/AccountDrawer/Status.tsx +++ b/apps/web/src/components/AccountDrawer/Status.tsx @@ -1,4 +1,3 @@ -import { AddressDisplay } from 'components/AccountDetails/AddressDisplay' import Column from 'components/Column' import { ENS } from 'components/Icons/ENS' import { EthMini } from 'components/Icons/EthMini' @@ -6,10 +5,11 @@ import StatusIcon from 'components/Identicon/StatusIcon' import Popover from 'components/Popover' import Row from 'components/Row' import { useOnClickOutside } from 'hooks/useOnClickOutside' -import styled from 'lib/styled-components' import { useRef, useState } from 'react' import { MoreHorizontal } from 'react-feather' -import { ClickableStyle, CopyHelper, ThemedText } from 'theme/components' +import styled from 'styled-components' +import { ClickableStyle, CopyHelper, EllipsisStyle, ThemedText } from 'theme/components' +import { Unitag } from 'ui/src/components/icons' import { shortenAddress } from 'utilities/src/addresses' const Container = styled.div` @@ -26,6 +26,13 @@ const Identifiers = styled.div` overflow: hidden; flex: 1 1 auto; ` +const IdentifierText = styled.span` + ${EllipsisStyle} + max-width: 120px; + @media screen and (min-width: 1440px) { + max-width: 180px; + } +` const SecondaryIdentifiersContainer = styled(Row)` position: relative; user-select: none; @@ -73,7 +80,7 @@ function SecondaryIdentifier({ ) } -export function SecondaryIdentifiers({ +function SecondaryIdentifiers({ account, uniswapUsername, ensUsername, @@ -123,19 +130,26 @@ export function Status({ account, ensUsername, uniswapUsername, - showAddressCopy = true, }: { account: string ensUsername: string | null uniswapUsername?: string - showAddressCopy?: boolean }) { return ( - + + + {uniswapUsername ?? ensUsername ?? shortenAddress(account)} + {uniswapUsername && } + + {(uniswapUsername || ensUsername) && ( diff --git a/apps/web/src/components/AccountDrawer/UniwalletModal.tsx b/apps/web/src/components/AccountDrawer/UniwalletModal.tsx index 170e3d11755..960b07d1a11 100644 --- a/apps/web/src/components/AccountDrawer/UniwalletModal.tsx +++ b/apps/web/src/components/AccountDrawer/UniwalletModal.tsx @@ -8,9 +8,9 @@ import { useConnectorWithId } from 'components/WalletModal/useOrderedConnections import { CONNECTION } from 'components/Web3Provider/constants' import { useConnect } from 'hooks/useConnect' import { Trans } from 'i18n' -import styled, { useTheme } from 'lib/styled-components' import { QRCodeSVG } from 'qrcode.react' import { useCallback, useEffect, useState } from 'react' +import styled, { useTheme } from 'styled-components' import { CloseIcon, ThemedText } from 'theme/components' import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send' import { isWebAndroid, isWebIOS } from 'utilities/src/platform' @@ -69,8 +69,6 @@ export default function UniwalletModal() { useEffect(() => { if (open) { sendAnalyticsEvent(InterfaceEventName.UNIWALLET_CONNECT_MODAL_OPENED) - } else { - setUri(undefined) } }, [open]) diff --git a/apps/web/src/components/AccountDrawer/__snapshots__/index.test.tsx.snap b/apps/web/src/components/AccountDrawer/__snapshots__/index.test.tsx.snap deleted file mode 100644 index ccd52f478d3..00000000000 --- a/apps/web/src/components/AccountDrawer/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,1657 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`AccountDrawer tests AccountDrawer styles when isUniExtensionAvailable is false 1`] = ` - - .c7 { - box-sizing: border-box; - margin: 0; - min-width: 0; - width: 100%; -} - -.c14 { - box-sizing: border-box; - margin: 0; - min-width: 0; - -webkit-flex: 1; - -ms-flex: 1; - flex: 1; -} - -.c8 { - width: 100%; - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - padding: 0; - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - -webkit-box-pack: justify; - -webkit-justify-content: space-between; - -ms-flex-pack: justify; - justify-content: space-between; -} - -.c15 { - width: 100%; - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - padding: 0; - -webkit-align-items: flex-start; - -webkit-box-align: flex-start; - -ms-flex-align: flex-start; - align-items: flex-start; - -webkit-box-pack: start; - -webkit-justify-content: flex-start; - -ms-flex-pack: start; - justify-content: flex-start; -} - -.c9 { - -webkit-flex-wrap: wrap; - -ms-flex-wrap: wrap; - flex-wrap: wrap; - -webkit-box-pack: justify; - -webkit-justify-content: space-between; - -ms-flex-pack: justify; - justify-content: space-between; -} - -.c9 > * { - margin: !important; -} - -.c10 { - color: #222222; - -webkit-letter-spacing: -0.01em; - -moz-letter-spacing: -0.01em; - -ms-letter-spacing: -0.01em; - letter-spacing: -0.01em; -} - -.c24 { - color: #7D7D7D; - -webkit-letter-spacing: -0.01em; - -moz-letter-spacing: -0.01em; - -ms-letter-spacing: -0.01em; - letter-spacing: -0.01em; -} - -.c25 { - -webkit-text-decoration: none; - text-decoration: none; - cursor: pointer; - -webkit-transition-duration: 125ms; - transition-duration: 125ms; - color: #FC72FF; - stroke: #FC72FF; - font-weight: 500; -} - -.c25:hover { - opacity: 0.6; -} - -.c25:active { - opacity: 0.4; -} - -.c4 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-direction: column; - -ms-flex-direction: column; - flex-direction: column; - -webkit-box-pack: start; - -webkit-justify-content: flex-start; - -ms-flex-pack: start; - justify-content: flex-start; -} - -.c13 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-direction: column; - -ms-flex-direction: column; - flex-direction: column; - -webkit-box-pack: start; - -webkit-justify-content: flex-start; - -ms-flex-pack: start; - justify-content: flex-start; - gap: 12px; - -webkit-flex: 1; - -ms-flex: 1; - flex: 1; -} - -.c22 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-direction: column; - -ms-flex-direction: column; - flex-direction: column; - -webkit-box-pack: start; - -webkit-justify-content: flex-start; - -ms-flex-pack: start; - justify-content: flex-start; - gap: 12px; -} - -.c11 { - background-color: #FFFFFF; - -webkit-transition: width ease-in-out 125ms; - transition: width ease-in-out 125ms; - border-radius: 12px; - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - padding: 0; - cursor: pointer; - position: relative; - overflow: hidden; - height: 32px; - width: 32px; - color: #7D7D7D; - border: none; - outline: none; -} - -.c11:hover { - background-color: #F9F9F9; - -webkit-transition: 125ms background-color ease-in, width ease-in-out 125ms; - transition: 125ms background-color ease-in, width ease-in-out 125ms; -} - -.c11:active { - background-color: #FFFFFF; - -webkit-transition: background-color 125ms linear, width ease-in-out 125ms; - transition: background-color 125ms linear, width ease-in-out 125ms; -} - -.c12 { - width: 24px; - height: 24px; - margin: auto; - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - -webkit-box-pack: center; - -webkit-justify-content: center; - -ms-flex-pack: center; - justify-content: center; -} - -.c19 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-flow: column nowrap; - -ms-flex-flow: column nowrap; - flex-flow: column nowrap; - -webkit-flex-direction: row; - -ms-flex-direction: row; - flex-direction: row; - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; -} - -.c18 { - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - background-color: unset; - border: none; - cursor: pointer; - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex: 1 1 auto; - -ms-flex: 1 1 auto; - flex: 1 1 auto; - -webkit-flex-direction: row; - -ms-flex-direction: row; - flex-direction: row; - -webkit-box-pack: justify; - -webkit-justify-content: space-between; - -ms-flex-pack: justify; - justify-content: space-between; - opacity: 1; - padding: 18px; - -webkit-transition: 125ms; - transition: 125ms; -} - -.c21 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-flow: row nowrap; - -ms-flex-flow: row nowrap; - flex-flow: row nowrap; - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - -webkit-box-pack: center; - -webkit-justify-content: center; - -ms-flex-pack: center; - justify-content: center; - color: #222222; - font-size: 16px; - font-weight: 535; - padding: 0 8px; -} - -.c20 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-flow: column nowrap; - -ms-flex-flow: column nowrap; - flex-flow: column nowrap; - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - -webkit-box-pack: center; - -webkit-justify-content: center; - -ms-flex-pack: center; - justify-content: center; -} - -.c20 img { - border: 1px solid #22222212; - border-radius: 12px; -} - -.c20 > img, -.c20 span { - height: 40px; - width: 40px; -} - -.c17 { - -webkit-align-items: stretch; - -webkit-box-align: stretch; - -ms-flex-align: stretch; - align-items: stretch; - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-direction: row; - -ms-flex-direction: row; - flex-direction: row; - -webkit-box-pack: justify; - -webkit-justify-content: space-between; - -ms-flex-pack: justify; - justify-content: space-between; - position: relative; - width: 100%; - background-color: #F9F9F9; -} - -.c17:hover { - cursor: pointer; - background-color: #22222212; -} - -.c17:focus { - background-color: #22222212; -} - -.c26 { - font-weight: 535; - color: #7D7D7D; -} - -.c6 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-flow: column nowrap; - -ms-flex-flow: column nowrap; - flex-flow: column nowrap; - background-color: #FFFFFF; - width: 100%; - padding: 14px 16px 16px; - -webkit-flex: 1; - -ms-flex: 1; - flex: 1; - gap: 16px; -} - -.c16 { - display: grid; - -webkit-flex: 1; - -ms-flex: 1; - flex: 1; - grid-gap: 2px; - border-radius: 12px; - overflow: hidden; - opacity: 1; - max-height: 100vh; - -webkit-transition: max-height 125ms ease-in-out,opacity 125ms ease-in-out; - transition: max-height 125ms ease-in-out,opacity 125ms ease-in-out; -} - -.c23 { - padding: 0 4px; -} - -.c5 { - width: 100%; - height: 100%; -} - -.c1 { - z-index: 1040; - overflow: hidden; - top: 0; - left: 0; - position: fixed; - width: 100%; - height: 100%; - background-color: rgba(0,0,0,0.60); - opacity: 0; - pointer-events: none; -} - -.c3 { - overflow-y: auto; - overflow-x: hidden; - -webkit-scrollbar-width: thin; - -moz-scrollbar-width: thin; - -ms-scrollbar-width: thin; - scrollbar-width: thin; - -webkit-scrollbar-color: #22222212 transparent; - -moz-scrollbar-color: #22222212 transparent; - -ms-scrollbar-color: #22222212 transparent; - scrollbar-color: #22222212 transparent; - height: 100%; - -webkit-scrollbar-gutter: stable; - -moz-scrollbar-gutter: stable; - -ms-scrollbar-gutter: stable; - scrollbar-gutter: stable; - overscroll-behavior: contain; - border-radius: 12px; -} - -.c3::-webkit-scrollbar { - background: transparent; - width: 4px; - overflow-y: scroll; -} - -.c3::-webkit-scrollbar-thumb { - background: #22222212; - border-radius: 8px; -} - -.c0 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-direction: row; - -ms-flex-direction: row; - flex-direction: row; - height: calc(100% - 2 * 8px); - overflow: hidden; - position: fixed; - right: 8px; - top: 8px; - z-index: 1030; -} - -.c2 { - margin-right: -320px; - height: 100%; - overflow: hidden; - border-radius: 12px; - width: 320px; - max-width: 320px; - font-size: 16px; - background-color: #FFFFFF; - border: 1px solid #22222212; - box-shadow: 8px 12px 20px rgba(51,53,72,0.04),4px 6px 12px rgba(51,53,72,0.02),4px 4px 8px rgba(51,53,72,0.04); - -webkit-transition: margin-right 250ms; - transition: margin-right 250ms; -} - -@media (max-width:960px) { - .c20 { - -webkit-align-items: flex-end; - -webkit-box-align: flex-end; - -ms-flex-align: flex-end; - align-items: flex-end; - } -} - -@media (max-width:960px) { - .c16 { - grid-template-columns: 1fr; - } -} - -@media only screen and (max-width:640px) { - .c1 { - opacity: 0; - pointer-events: none; - -webkit-transition: opacity 250ms ease-in-out; - transition: opacity 250ms ease-in-out; - } -} - -@media only screen and (max-width:640px) { - .c0 { - height: 100%; - top: 100%; - left: 0; - right: 0; - width: 100%; - overflow: visible; - } -} - -@media only screen and (max-width:640px) { - .c2 { - z-index: 1060; - position: absolute; - margin-right: 0; - top: 0; - height: calc(100% - 72px); - width: 100%; - max-width: 100%; - border-bottom-right-radius: 0px; - border-bottom-left-radius: 0px; - box-shadow: unset; - -webkit-transition: top 250ms; - transition: top 250ms; - } -} - -@media screen and (min-width:1440px) { - .c2 { - margin-right: -390px; - width: 390px; - max-width: 390px; - } -} - - - -
-
-
-
-
-
-
-
-
- Connect a wallet -
- -
-
-
-
-
- -
-
- -
-
- -
-
-
-
-
-
- By connecting a wallet, you agree to Uniswap Labs’ - - Terms of Service - - and consent to its - - Privacy Policy. - -
-
-
-
-
-
-
-
-
- - - -`; - -exports[`AccountDrawer tests AccountDrawer styles when isUniExtensionAvailable is true 1`] = ` - - .c7 { - box-sizing: border-box; - margin: 0; - min-width: 0; - width: 100%; -} - -.c15 { - box-sizing: border-box; - margin: 0; - min-width: 0; -} - -.c19 { - box-sizing: border-box; - margin: 0; - min-width: 0; - padding: 8px 0px; -} - -.c23 { - box-sizing: border-box; - margin: 0; - min-width: 0; - margin-left: 18px; - margin-right: 18px; -} - -.c27 { - box-sizing: border-box; - margin: 0; - min-width: 0; - -webkit-flex: 1; - -ms-flex: 1; - flex: 1; -} - -.c8 { - width: 100%; - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - padding: 0; - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - -webkit-box-pack: justify; - -webkit-justify-content: space-between; - -ms-flex-pack: justify; - justify-content: space-between; -} - -.c16 { - width: 100%; - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - padding: 0; - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - -webkit-box-pack: start; - -webkit-justify-content: flex-start; - -ms-flex-pack: start; - justify-content: flex-start; - gap: 12px; -} - -.c18 { - width: 100%; - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - padding: 0; - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - -webkit-box-pack: start; - -webkit-justify-content: flex-start; - -ms-flex-pack: start; - justify-content: flex-start; - gap: 4px; -} - -.c20 { - width: 100%; - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - padding: 0; - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - -webkit-box-pack: start; - -webkit-justify-content: flex-start; - -ms-flex-pack: start; - justify-content: flex-start; - padding: 8px 0px; -} - -.c24 { - width: 100%; - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - padding: 0; - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - -webkit-box-pack: start; - -webkit-justify-content: flex-start; - -ms-flex-pack: start; - justify-content: flex-start; -} - -.c28 { - width: 100%; - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - padding: 0; - -webkit-align-items: flex-start; - -webkit-box-align: flex-start; - -ms-flex-align: flex-start; - align-items: flex-start; - -webkit-box-pack: start; - -webkit-justify-content: flex-start; - -ms-flex-pack: start; - justify-content: flex-start; -} - -.c9 { - -webkit-flex-wrap: wrap; - -ms-flex-wrap: wrap; - flex-wrap: wrap; - -webkit-box-pack: justify; - -webkit-justify-content: space-between; - -ms-flex-pack: justify; - justify-content: space-between; -} - -.c9 > * { - margin: !important; -} - -.c10 { - color: #222222; - -webkit-letter-spacing: -0.01em; - -moz-letter-spacing: -0.01em; - -ms-letter-spacing: -0.01em; - letter-spacing: -0.01em; -} - -.c36 { - color: #7D7D7D; - -webkit-letter-spacing: -0.01em; - -moz-letter-spacing: -0.01em; - -ms-letter-spacing: -0.01em; - letter-spacing: -0.01em; -} - -.c37 { - -webkit-text-decoration: none; - text-decoration: none; - cursor: pointer; - -webkit-transition-duration: 125ms; - transition-duration: 125ms; - color: #FC72FF; - stroke: #FC72FF; - font-weight: 500; -} - -.c37:hover { - opacity: 0.6; -} - -.c37:active { - opacity: 0.4; -} - -.c4 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-direction: column; - -ms-flex-direction: column; - flex-direction: column; - -webkit-box-pack: start; - -webkit-justify-content: flex-start; - -ms-flex-pack: start; - justify-content: flex-start; -} - -.c13 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-direction: column; - -ms-flex-direction: column; - flex-direction: column; - -webkit-box-pack: start; - -webkit-justify-content: flex-start; - -ms-flex-pack: start; - justify-content: flex-start; - gap: 16px; -} - -.c14 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-direction: column; - -ms-flex-direction: column; - flex-direction: column; - -webkit-box-pack: start; - -webkit-justify-content: flex-start; - -ms-flex-pack: start; - justify-content: flex-start; - gap: 12px; -} - -.c26 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-direction: column; - -ms-flex-direction: column; - flex-direction: column; - -webkit-box-pack: start; - -webkit-justify-content: flex-start; - -ms-flex-pack: start; - justify-content: flex-start; - gap: 12px; - -webkit-flex: 1; - -ms-flex: 1; - flex: 1; -} - -.c11 { - background-color: #FFFFFF; - -webkit-transition: width ease-in-out 125ms; - transition: width ease-in-out 125ms; - border-radius: 12px; - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - padding: 0; - cursor: pointer; - position: relative; - overflow: hidden; - height: 32px; - width: 32px; - color: #7D7D7D; - border: none; - outline: none; -} - -.c11:hover { - background-color: #F9F9F9; - -webkit-transition: 125ms background-color ease-in, width ease-in-out 125ms; - transition: 125ms background-color ease-in, width ease-in-out 125ms; -} - -.c11:active { - background-color: #FFFFFF; - -webkit-transition: background-color 125ms linear, width ease-in-out 125ms; - transition: background-color 125ms linear, width ease-in-out 125ms; -} - -.c12 { - width: 24px; - height: 24px; - margin: auto; - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - -webkit-box-pack: center; - -webkit-justify-content: center; - -ms-flex-pack: center; - justify-content: center; -} - -.c32 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-flow: column nowrap; - -ms-flex-flow: column nowrap; - flex-flow: column nowrap; - -webkit-flex-direction: row; - -ms-flex-direction: row; - flex-direction: row; - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; -} - -.c31 { - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - background-color: unset; - border: none; - cursor: pointer; - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex: 1 1 auto; - -ms-flex: 1 1 auto; - flex: 1 1 auto; - -webkit-flex-direction: row; - -ms-flex-direction: row; - flex-direction: row; - -webkit-box-pack: justify; - -webkit-justify-content: space-between; - -ms-flex-pack: justify; - justify-content: space-between; - opacity: 1; - padding: 18px; - -webkit-transition: 125ms; - transition: 125ms; -} - -.c34 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-flow: row nowrap; - -ms-flex-flow: row nowrap; - flex-flow: row nowrap; - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - -webkit-box-pack: center; - -webkit-justify-content: center; - -ms-flex-pack: center; - justify-content: center; - color: #222222; - font-size: 16px; - font-weight: 535; - padding: 0 8px; -} - -.c33 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-flow: column nowrap; - -ms-flex-flow: column nowrap; - flex-flow: column nowrap; - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - -webkit-box-pack: center; - -webkit-justify-content: center; - -ms-flex-pack: center; - justify-content: center; -} - -.c33 img { - border: 1px solid #22222212; - border-radius: 12px; -} - -.c33 > img, -.c33 span { - height: 40px; - width: 40px; -} - -.c30 { - -webkit-align-items: stretch; - -webkit-box-align: stretch; - -ms-flex-align: stretch; - align-items: stretch; - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-direction: row; - -ms-flex-direction: row; - flex-direction: row; - -webkit-box-pack: justify; - -webkit-justify-content: space-between; - -ms-flex-pack: justify; - justify-content: space-between; - position: relative; - width: 100%; - background-color: #F9F9F9; -} - -.c30:hover { - cursor: pointer; - background-color: #22222212; -} - -.c30:focus { - background-color: #22222212; -} - -.c38 { - font-weight: 535; - color: #7D7D7D; -} - -.c17 { - padding: 16px; - gap: 12px; - border-radius: 16px; - border: 1px solid #22222212; - overflow: hidden; - max-height: 72px; - -webkit-box-pack: justify; - -webkit-justify-content: space-between; - -ms-flex-pack: justify; - justify-content: space-between; - cursor: pointer; - position: relative; - z-index: 1; -} - -.c17:hover { - background: #22222212; -} - -.c6 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-flow: column nowrap; - -ms-flex-flow: column nowrap; - flex-flow: column nowrap; - background-color: #FFFFFF; - width: 100%; - padding: 0px 16px 16px; - -webkit-flex: 1; - -ms-flex: 1; - flex: 1; - gap: 16px; -} - -.c29 { - display: grid; - -webkit-flex: 1; - -ms-flex: 1; - flex: 1; - grid-gap: 2px; - border-radius: 12px; - overflow: hidden; - opacity: 1; - max-height: 100vh; - -webkit-transition: max-height 125ms ease-in-out,opacity 125ms ease-in-out; - transition: max-height 125ms ease-in-out,opacity 125ms ease-in-out; -} - -.c35 { - padding: 0 4px; -} - -.c21 { - -webkit-text-decoration: none; - text-decoration: none; - cursor: pointer; - -webkit-transition-duration: 125ms; - transition-duration: 125ms; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; -} - -.c21:hover { - opacity: 0.6; -} - -.c21:active { - opacity: 0.4; -} - -.c22 { - height: 1px; - width: 100%; - background: #22222212; -} - -.c25 { - height: 20px; - width: 20px; - fill: #7D7D7D; - -webkit-flex-shrink: 0; - -ms-flex-negative: 0; - flex-shrink: 0; -} - -.c5 { - width: 100%; - height: 100%; -} - -.c1 { - z-index: 1040; - overflow: hidden; - top: 0; - left: 0; - position: fixed; - width: 100%; - height: 100%; - background-color: rgba(0,0,0,0.60); - opacity: 0; - pointer-events: none; -} - -.c3 { - overflow-y: auto; - overflow-x: hidden; - -webkit-scrollbar-width: thin; - -moz-scrollbar-width: thin; - -ms-scrollbar-width: thin; - scrollbar-width: thin; - -webkit-scrollbar-color: #22222212 transparent; - -moz-scrollbar-color: #22222212 transparent; - -ms-scrollbar-color: #22222212 transparent; - scrollbar-color: #22222212 transparent; - height: 100%; - -webkit-scrollbar-gutter: stable; - -moz-scrollbar-gutter: stable; - -ms-scrollbar-gutter: stable; - scrollbar-gutter: stable; - overscroll-behavior: contain; - border-radius: 12px; -} - -.c3::-webkit-scrollbar { - background: transparent; - width: 4px; - overflow-y: scroll; -} - -.c3::-webkit-scrollbar-thumb { - background: #22222212; - border-radius: 8px; -} - -.c0 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-direction: row; - -ms-flex-direction: row; - flex-direction: row; - height: calc(100% - 2 * 8px); - overflow: hidden; - position: fixed; - right: 8px; - top: 8px; - z-index: 1030; - height: auto; - max-height: calc(100% - 88px); - right: 24px; - top: 72px; - -webkit-scrollbar-width: thin; - -moz-scrollbar-width: thin; - -ms-scrollbar-width: thin; - scrollbar-width: thin; - -webkit-scrollbar-color: #22222212 transparent; - -moz-scrollbar-color: #22222212 transparent; - -ms-scrollbar-color: #22222212 transparent; - scrollbar-color: #22222212 transparent; - height: 100%; -} - -.c0::-webkit-scrollbar { - background: transparent; - width: 4px; - overflow-y: scroll; -} - -.c0::-webkit-scrollbar-thumb { - background: #22222212; - border-radius: 8px; -} - -.c2 { - margin-right: -368px; - height: 100%; - overflow: hidden; - border-radius: 12px; - width: 320px; - max-width: 320px; - font-size: 16px; - background-color: #FFFFFF; - border: 1px solid #22222212; - box-shadow: 8px 12px 20px rgba(51,53,72,0.04),4px 6px 12px rgba(51,53,72,0.02),4px 4px 8px rgba(51,53,72,0.04); - -webkit-transition: margin-right 250ms; - transition: margin-right 250ms; - height: -webkit-max-content; - height: -moz-max-content; - height: max-content; - max-height: 100%; - width: 368px; - max-width: 368px; - border-radius: 20px; - box-shadow: 8px 12px 20px rgba(51,53,72,0.04),4px 6px 12px rgba(51,53,72,0.02),4px 4px 8px rgba(51,53,72,0.04); - -webkit-transform: scale(0.96); - -ms-transform: scale(0.96); - transform: scale(0.96); - -webkit-transform-origin: top right; - -ms-transform-origin: top right; - transform-origin: top right; - opacity: 0; - overflow-y: scroll; - -webkit-transition: -webkit-transform 125ms ease-in-out, opacity 125ms ease-in-out; - -webkit-transition: transform 125ms ease-in-out, opacity 125ms ease-in-out; - transition: transform 125ms ease-in-out, opacity 125ms ease-in-out; -} - -@media (max-width:960px) { - .c33 { - -webkit-align-items: flex-end; - -webkit-box-align: flex-end; - -ms-flex-align: flex-end; - align-items: flex-end; - } -} - -@media (max-width:960px) { - .c29 { - grid-template-columns: 1fr; - } -} - -@media only screen and (max-width:640px) { - .c1 { - opacity: 0; - pointer-events: none; - -webkit-transition: opacity 250ms ease-in-out; - transition: opacity 250ms ease-in-out; - } -} - -@media only screen and (max-width:640px) { - .c0 { - height: 100%; - top: 100%; - left: 0; - right: 0; - width: 100%; - overflow: visible; - } -} - -@media only screen and (max-width:640px) { - .c2 { - z-index: 1060; - position: absolute; - margin-right: 0; - top: 0; - height: calc(100% - 72px); - width: 100%; - max-width: 100%; - border-bottom-right-radius: 0px; - border-bottom-left-radius: 0px; - box-shadow: unset; - -webkit-transition: top 250ms; - transition: top 250ms; - } -} - -@media screen and (min-width:1440px) { - .c2 { - margin-right: -390px; - width: 390px; - max-width: 390px; - } -} - - - -
-
-
-
-
-
-
-
-
- Connect a wallet -
- -
-
-
-
- - - -
-
- - Uniswap Mobile - - - Scan QR code to connect - -
-
-
-
-
-
-
-
- - Other wallets - - - - - -
-
-
-
-
-
-
- -
-
- -
-
-
-
-
-
- By connecting a wallet, you agree to Uniswap Labs’ - - Terms of Service - - and consent to its - - Privacy Policy. - -
-
-
-
-
-
-
-
-
- - - -`; diff --git a/apps/web/src/components/AccountDrawer/index.test.tsx b/apps/web/src/components/AccountDrawer/index.test.tsx deleted file mode 100644 index 9d041bf0e19..00000000000 --- a/apps/web/src/components/AccountDrawer/index.test.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import AccountDrawer, { DRAWER_WIDTH, MODAL_WIDTH } from 'components/AccountDrawer' -import { useIsUniExtensionAvailable, useUniswapWalletOptions } from 'hooks/useUniswapWalletOptions' -import { mocked } from 'test-utils/mocked' -import { render, screen } from 'test-utils/render' - -jest.mock('hooks/useUniswapWalletOptions', () => ({ - useIsUniExtensionAvailable: jest.fn(), - useUniswapWalletOptions: jest.fn(), -})) - -describe('AccountDrawer tests', () => { - it('AccountDrawer styles when isUniExtensionAvailable is false', () => { - mocked(useUniswapWalletOptions).mockReturnValue(false) - mocked(useIsUniExtensionAvailable).mockReturnValue(false) - - const { asFragment } = render() - expect(asFragment()).toMatchSnapshot() - const drawerWrapper = screen.getByTestId('account-drawer') - expect(drawerWrapper).toBeInTheDocument() - expect(drawerWrapper).toHaveStyleRule('width', DRAWER_WIDTH) - }) - - it('AccountDrawer styles when isUniExtensionAvailable is true', () => { - mocked(useUniswapWalletOptions).mockReturnValue(true) - mocked(useIsUniExtensionAvailable).mockReturnValue(true) - - const { asFragment } = render() - expect(asFragment()).toMatchSnapshot() - const drawerWrapper = screen.getByTestId('account-drawer') - expect(drawerWrapper).toBeInTheDocument() - expect(drawerWrapper).toHaveStyleRule('width', MODAL_WIDTH) - }) -}) diff --git a/apps/web/src/components/AccountDrawer/index.tsx b/apps/web/src/components/AccountDrawer/index.tsx index 110a4d2b04e..10f22cb519f 100644 --- a/apps/web/src/components/AccountDrawer/index.tsx +++ b/apps/web/src/components/AccountDrawer/index.tsx @@ -2,17 +2,13 @@ import { InterfaceEventName } from '@uniswap/analytics-events' import DefaultMenu from 'components/AccountDrawer/DefaultMenu' import { useAccountDrawer } from 'components/AccountDrawer/MiniPortfolio/hooks' import { ScrollBarStyles } from 'components/Common' -import { Web3StatusRef } from 'components/Web3Status' import { useWindowSize } from 'hooks/screenSize' import useDisableScrolling from 'hooks/useDisableScrolling' -import { useOnClickOutside } from 'hooks/useOnClickOutside' import usePrevious from 'hooks/usePrevious' -import { useIsUniExtensionAvailable } from 'hooks/useUniswapWalletOptions' -import { useAtom } from 'jotai' -import styled, { css } from 'lib/styled-components' import { useEffect, useRef, useState } from 'react' import { ChevronsRight } from 'react-feather' import { useGesture } from 'react-use-gesture' +import styled from 'styled-components' import { BREAKPOINTS } from 'theme' import { ClickableStyle } from 'theme/components' import { Z_INDEX } from 'theme/zIndex' @@ -20,12 +16,10 @@ import Trace from 'uniswap/src/features/telemetry/Trace' import { isMobile } from 'utilities/src/platform' const DRAWER_WIDTH_XL = '390px' -export const DRAWER_WIDTH = '320px' +const DRAWER_WIDTH = '320px' const DRAWER_MARGIN = '8px' const DRAWER_OFFSET = '10px' -export const MODAL_WIDTH = '368px' - const ScrimBackground = styled.div<{ $open: boolean; $maxWidth?: number; $zIndex?: number }>` z-index: ${({ $zIndex }) => $zIndex ?? Z_INDEX.modalBackdrop}; overflow: hidden; @@ -77,7 +71,7 @@ const AccountDrawerScrollWrapper = styled.div` border-radius: 12px; ` -const Container = styled.div<{ isUniExtensionAvailable?: boolean }>` +const Container = styled.div` display: flex; flex-direction: row; height: calc(100% - 2 * ${DRAWER_MARGIN}); @@ -87,8 +81,6 @@ const Container = styled.div<{ isUniExtensionAvailable?: boolean }>` top: ${DRAWER_MARGIN}; z-index: ${Z_INDEX.fixed}; - ${({ isUniExtensionAvailable }) => isUniExtensionAvailable && ExtensionContainerStyles} - @media only screen and (max-width: ${({ theme }) => `${theme.breakpoint.sm}px`}) { height: 100%; top: 100%; @@ -99,17 +91,8 @@ const Container = styled.div<{ isUniExtensionAvailable?: boolean }>` } ` -const ExtensionContainerStyles = css` - height: auto; - max-height: calc(100% - ${({ theme }) => theme.navHeight + 16}px); - right: 24px; - top: ${({ theme }) => theme.navHeight}px; - ${ScrollBarStyles} -` - -const AccountDrawerWrapper = styled.div<{ open: boolean; isUniExtensionAvailable?: boolean }>` - margin-right: ${({ open, isUniExtensionAvailable }) => - open ? 0 : '-' + (isUniExtensionAvailable ? MODAL_WIDTH : DRAWER_WIDTH)}; +const AccountDrawerWrapper = styled.div<{ open: boolean }>` + margin-right: ${({ open }) => (open ? 0 : '-' + DRAWER_WIDTH)}; height: 100%; overflow: hidden; @@ -143,23 +126,6 @@ const AccountDrawerWrapper = styled.div<{ open: boolean; isUniExtensionAvailable box-shadow: ${({ theme }) => theme.deprecated_deepShadow}; transition: margin-right ${({ theme }) => theme.transition.duration.medium}; - - ${({ isUniExtensionAvailable }) => isUniExtensionAvailable && ExtensionDrawerWrapperStyles} -` - -const ExtensionDrawerWrapperStyles = css<{ open: boolean }>` - height: max-content; - max-height: 100%; - width: ${MODAL_WIDTH}; - max-width: ${MODAL_WIDTH}; - border-radius: 20px; - box-shadow: ${({ theme }) => theme.deprecated_deepShadow}; - transform: scale(${({ open }) => (open ? 1 : 0.96)}); - transform-origin: top right; - opacity: ${({ open }) => (open ? 1 : 0)}; - overflow-y: scroll; - transition: ${({ theme }) => `transform ${theme.transition.duration.fast} ${theme.transition.timing.inOut}, - opacity ${theme.transition.duration.fast} ${theme.transition.timing.inOut}`}; ` const CloseIcon = styled(ChevronsRight).attrs({ size: 24 })` @@ -189,22 +155,6 @@ function AccountDrawer() { const accountDrawer = useAccountDrawer() const wasAccountDrawerOpen = usePrevious(accountDrawer.isOpen) const scrollRef = useRef(null) - const modalRef = useRef(null) - const isUniExtensionAvailable = useIsUniExtensionAvailable() - const [web3StatusRef] = useAtom(Web3StatusRef) - - useOnClickOutside( - modalRef, - () => { - if (isUniExtensionAvailable) { - accountDrawer.close() - } - }, - // Prevents quick close & re-open when tapping the Web3Status - // stopPropagation does not work here - web3StatusRef ? [web3StatusRef] : [], - ) - useEffect(() => { if (wasAccountDrawerOpen && !accountDrawer.isOpen) { scrollRef.current?.scrollTo({ top: 0, behavior: 'smooth' }) @@ -276,8 +226,8 @@ function AccountDrawer() { }) return ( - - {accountDrawer.isOpen && !isUniExtensionAvailable && ( + + {accountDrawer.isOpen && ( @@ -286,9 +236,6 @@ function AccountDrawer() { )} { - toggleModal() - openReceiveCryptoModal() - }, [toggleModal, openReceiveCryptoModal]) - - return ( - - - - - - {hasSecondaryIdentifier && ( - - - - )} - - - - - - - - - - - - - - - - - - - - ) -} diff --git a/apps/web/src/components/AddressQRModal/useAvatarColorProps.tsx b/apps/web/src/components/AddressQRModal/useAvatarColorProps.tsx deleted file mode 100644 index 9cf7c4b2381..00000000000 --- a/apps/web/src/components/AddressQRModal/useAvatarColorProps.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import useENSAvatar from 'hooks/useENSAvatar' -import { useMemo } from 'react' -import { - GradientProps, - getUniconColors, - passesContrast, - useExtractedColors, - useIsDarkMode, - useSporeColors, -} from 'ui/src' -import { useUnitagByAddress } from 'uniswap/src/features/unitags/hooks' - -// Fetches avatar for address, in priority uses: unitag avatar, ens avatar, undefined -// Note that this hook is used instead of just useENSAvatar because our implementation -// of useENSAvatar checks for reverse name resolution which Unitags does not support. -// Chose to do this because even if we used useENSAvatar without reverse name resolution, -// there is more latency because it has to go to the contract via CCIP-read first. -function useAvatar(address: string | undefined): { - avatar: Maybe - loading: boolean -} { - const { unitag, loading: unitagLoading } = useUnitagByAddress(address) - const { avatar: ensAvatar, loading: ensLoading } = useENSAvatar(address) - const unitagAvatar = unitag?.metadata?.avatar - - if (!address) { - return { loading: false, avatar: undefined } - } - - if (unitagAvatar) { - return { avatar: unitagAvatar, loading: false } - } - - if (ensAvatar) { - return { avatar: ensAvatar, loading: false } - } - - return { avatar: undefined, loading: ensLoading || unitagLoading } -} - -type AvatarColors = { - primary: string - base: string - detail: string -} - -type ColorProps = { - smartColor: string - gradientProps: GradientProps -} - -export const useAvatarColorProps = (address: Address): ColorProps => { - const colors = useSporeColors() - const isDarkMode = useIsDarkMode() - const { color: uniconColor } = getUniconColors(address, isDarkMode) as { color: string } - const { avatar, loading: avatarLoading } = useAvatar(address) - const { colors: avatarColors } = useExtractedColors(avatar) as { colors: AvatarColors } - const hasAvatar = !!avatar && !avatarLoading - - const smartColor: string = useMemo(() => { - const contrastThreshold = 3 // WCAG AA standard for contrast - const backgroundColor = colors.surface2.val // replace with your actual background color - - if (hasAvatar && avatarColors && avatarColors.primary) { - if (passesContrast(avatarColors.primary, backgroundColor, contrastThreshold)) { - return avatarColors.primary - } - if (passesContrast(avatarColors.base, backgroundColor, contrastThreshold)) { - return avatarColors.base - } - if (passesContrast(avatarColors.detail, backgroundColor, contrastThreshold)) { - return avatarColors.detail - } - // Modify the color if it doesn't pass the contrast check - // Replace 'modifiedColor' with the actual color you want to use - return colors.neutral1.val as string - } - return uniconColor - }, [avatarColors, hasAvatar, uniconColor, colors.surface2.val, colors.neutral1.val]) - - return { smartColor, gradientProps: {} } -} diff --git a/apps/web/src/components/Badge/RangeBadge.tsx b/apps/web/src/components/Badge/RangeBadge.tsx index 5befc16a237..c1a21524d40 100644 --- a/apps/web/src/components/Badge/RangeBadge.tsx +++ b/apps/web/src/components/Badge/RangeBadge.tsx @@ -1,7 +1,7 @@ import { MouseoverTooltip } from 'components/Tooltip' import { Trans } from 'i18n' -import styled, { useTheme } from 'lib/styled-components' import { AlertTriangle, Slash } from 'react-feather' +import styled, { useTheme } from 'styled-components' const BadgeWrapper = styled.div` font-size: 14px; diff --git a/apps/web/src/components/Badge/index.tsx b/apps/web/src/components/Badge/index.tsx index 7a816e62739..50ebce1f4df 100644 --- a/apps/web/src/components/Badge/index.tsx +++ b/apps/web/src/components/Badge/index.tsx @@ -1,6 +1,6 @@ -import styled, { DefaultTheme } from 'lib/styled-components' import { readableColor } from 'polished' import { PropsWithChildren } from 'react' +import styled, { DefaultTheme } from 'styled-components' export enum BadgeVariant { DEFAULT = 'DEFAULT', diff --git a/apps/web/src/components/Banner/Outage/OutageBanner.tsx b/apps/web/src/components/Banner/Outage/OutageBanner.tsx index 652b2f229e4..33bcbae705e 100644 --- a/apps/web/src/components/Banner/Outage/OutageBanner.tsx +++ b/apps/web/src/components/Banner/Outage/OutageBanner.tsx @@ -2,9 +2,9 @@ import { Container, PopupContainer, StyledXButton, TextContainer } from 'compone import { chainIdToBackendChain } from 'constants/chains' import { ChainOutageData } from 'featureFlags/flags/outageBanner' import { Trans } from 'i18n' -import styled, { useTheme } from 'lib/styled-components' import { useState } from 'react' import { Globe } from 'react-feather' +import styled, { useTheme } from 'styled-components' import { ExternalLink, ThemedText } from 'theme/components' import { capitalize } from 'tsafe' import { UniverseChainId } from 'uniswap/src/types/chains' diff --git a/apps/web/src/components/Banner/shared/styled.tsx b/apps/web/src/components/Banner/shared/styled.tsx index 71fb9a6999c..a9633549142 100644 --- a/apps/web/src/components/Banner/shared/styled.tsx +++ b/apps/web/src/components/Banner/shared/styled.tsx @@ -1,6 +1,6 @@ import { OpacityHoverState } from 'components/Common' -import styled from 'lib/styled-components' import { X } from 'react-feather' +import styled from 'styled-components' import { BREAKPOINTS } from 'theme' import { Z_INDEX } from 'theme/zIndex' diff --git a/apps/web/src/components/BreadcrumbNav/index.tsx b/apps/web/src/components/BreadcrumbNav/index.tsx index b74d044ff4e..ce151ee05ed 100644 --- a/apps/web/src/components/BreadcrumbNav/index.tsx +++ b/apps/web/src/components/BreadcrumbNav/index.tsx @@ -4,10 +4,10 @@ import Tooltip, { TooltipSize } from 'components/Tooltip' import { useScreenSize } from 'hooks/screenSize' import useCopyClipboard from 'hooks/useCopyClipboard' import { Trans, t } from 'i18n' -import styled, { useTheme } from 'lib/styled-components' import { useCallback, useState } from 'react' import { Copy } from 'react-feather' import { Link } from 'react-router-dom' +import styled, { useTheme } from 'styled-components' import { ClickableStyle } from 'theme/components' import { shortenAddress } from 'utilities/src/addresses' diff --git a/apps/web/src/components/Button/GetHelp.tsx b/apps/web/src/components/Button/GetHelp.tsx index fa242990aef..e09be814654 100644 --- a/apps/web/src/components/Button/GetHelp.tsx +++ b/apps/web/src/components/Button/GetHelp.tsx @@ -1,7 +1,7 @@ import { EnvelopeHeartIcon } from 'components/Icons/EnvelopeHeart' import Row from 'components/Row' import { Trans } from 'i18n' -import styled from 'lib/styled-components' +import styled from 'styled-components' import { ExternalLink } from 'theme/components' import { uniswapUrls } from 'uniswap/src/constants/urls' diff --git a/apps/web/src/components/Button/index.tsx b/apps/web/src/components/Button/index.tsx index 51c4e2d8a1b..67b6207650c 100644 --- a/apps/web/src/components/Button/index.tsx +++ b/apps/web/src/components/Button/index.tsx @@ -1,9 +1,9 @@ import { RowBetween } from 'components/Row' -import styled, { DefaultTheme, useTheme } from 'lib/styled-components' import { darken } from 'polished' import { forwardRef } from 'react' import { Check, ChevronDown } from 'react-feather' import { ButtonProps as ButtonPropsOriginal, Button as RebassButton } from 'rebass/styled-components' +import styled, { DefaultTheme, useTheme } from 'styled-components' export { default as LoadingButtonSpinner } from './LoadingButtonSpinner' diff --git a/apps/web/src/components/Card/index.tsx b/apps/web/src/components/Card/index.tsx index b53036bd0a0..9fb4767ff03 100644 --- a/apps/web/src/components/Card/index.tsx +++ b/apps/web/src/components/Card/index.tsx @@ -1,5 +1,5 @@ -import styled from 'lib/styled-components' import { Box } from 'rebass/styled-components' +import styled from 'styled-components' const Card = styled(Box)<{ width?: string; padding?: string; border?: string; $borderRadius?: string }>` width: ${({ width }) => width ?? '100%'}; diff --git a/apps/web/src/components/Charts/ChartHeader.tsx b/apps/web/src/components/Charts/ChartHeader.tsx index 138877d5c65..8104a75884f 100644 --- a/apps/web/src/components/Charts/ChartHeader.tsx +++ b/apps/web/src/components/Charts/ChartHeader.tsx @@ -2,9 +2,9 @@ import { useHeaderDateFormatter } from 'components/Charts/hooks' import Column from 'components/Column' import Row from 'components/Row' import { getProtocolColor, getProtocolName } from 'graphql/data/util' -import styled, { useTheme } from 'lib/styled-components' import { UTCTimestamp } from 'lightweight-charts' import { ReactElement, ReactNode } from 'react' +import styled, { useTheme } from 'styled-components' import { EllipsisStyle } from 'theme/components' import { ThemedText } from 'theme/components/text' import { textFadeIn } from 'theme/styles' diff --git a/apps/web/src/components/Charts/ChartModel.tsx b/apps/web/src/components/Charts/ChartModel.tsx index 683cbf24c94..9a34b582f53 100644 --- a/apps/web/src/components/Charts/ChartModel.tsx +++ b/apps/web/src/components/Charts/ChartModel.tsx @@ -8,7 +8,6 @@ import { useActiveLocale } from 'hooks/useActiveLocale' import { useOnClickOutside } from 'hooks/useOnClickOutside' import { Trans } from 'i18n' import { useUpdateAtom } from 'jotai/utils' -import styled, { DefaultTheme, useTheme } from 'lib/styled-components' import { BarPrice, CrosshairMode, @@ -21,6 +20,7 @@ import { createChart, } from 'lightweight-charts' import { ReactElement, useEffect, useMemo, useRef, useState } from 'react' +import styled, { DefaultTheme, useTheme } from 'styled-components' import { ThemedText } from 'theme/components' import { textFadeIn } from 'theme/styles' import { Z_INDEX } from 'theme/zIndex' diff --git a/apps/web/src/components/Charts/LoadingState.tsx b/apps/web/src/components/Charts/LoadingState.tsx index 98db0c3e61d..c09dcaaaf35 100644 --- a/apps/web/src/components/Charts/LoadingState.tsx +++ b/apps/web/src/components/Charts/LoadingState.tsx @@ -3,9 +3,9 @@ import Column from 'components/Column' import Row from 'components/Row' import { MissingDataIcon } from 'components/Table/icons' import { Trans } from 'i18n' -import styled, { useTheme } from 'lib/styled-components' import { lighten } from 'polished' import { PropsWithChildren, ReactNode } from 'react' +import styled, { useTheme } from 'styled-components' import { ThemedText } from 'theme/components' import { textFadeIn } from 'theme/styles' import { opacify } from 'theme/utils' diff --git a/apps/web/src/components/Charts/PriceChart/index.tsx b/apps/web/src/components/Charts/PriceChart/index.tsx index 5a4d6e43937..7fe21437037 100644 --- a/apps/web/src/components/Charts/PriceChart/index.tsx +++ b/apps/web/src/components/Charts/PriceChart/index.tsx @@ -9,7 +9,6 @@ import { PriceChartType } from 'components/Charts/utils' import { RowBetween } from 'components/Row' import { DeltaArrow, DeltaText, calculateDelta } from 'components/Tokens/TokenDetails/Delta' import { Trans } from 'i18n' -import styled from 'lib/styled-components' import { AreaData, AreaSeriesPartialOptions, @@ -23,6 +22,7 @@ import { UTCTimestamp, } from 'lightweight-charts' import { useMemo } from 'react' +import styled from 'styled-components' import { ThemedText } from 'theme/components' import { opacify } from 'theme/utils' import { NumberType, useFormatter } from 'utils/formatNumbers' diff --git a/apps/web/src/components/Charts/SparklineChart/LineChart.tsx b/apps/web/src/components/Charts/SparklineChart/LineChart.tsx index 6278dd6c934..3cb3fd376c9 100644 --- a/apps/web/src/components/Charts/SparklineChart/LineChart.tsx +++ b/apps/web/src/components/Charts/SparklineChart/LineChart.tsx @@ -1,8 +1,8 @@ import { Group } from '@visx/group' import { LinePath } from '@visx/shape' import { CurveFactory } from 'd3' -import { useTheme } from 'lib/styled-components' import React, { ReactNode } from 'react' +import { useTheme } from 'styled-components' interface LineChartProps { data: T[] diff --git a/apps/web/src/components/Charts/SparklineChart/index.tsx b/apps/web/src/components/Charts/SparklineChart/index.tsx index 58f8e927806..778e9e3892f 100644 --- a/apps/web/src/components/Charts/SparklineChart/index.tsx +++ b/apps/web/src/components/Charts/SparklineChart/index.tsx @@ -4,8 +4,8 @@ import { LoadingBubble } from 'components/Tokens/loading' import { curveCardinal, scaleLinear } from 'd3' import { SparklineMap, TopToken } from 'graphql/data/TopTokens' import { PricePoint } from 'graphql/data/util' -import styled, { useTheme } from 'lib/styled-components' import { memo } from 'react' +import styled, { useTheme } from 'styled-components' const LoadingContainer = styled.div` height: 100%; diff --git a/apps/web/src/components/Charts/StackedLineChart/index.tsx b/apps/web/src/components/Charts/StackedLineChart/index.tsx index 904826ec0c6..ad297d7012e 100644 --- a/apps/web/src/components/Charts/StackedLineChart/index.tsx +++ b/apps/web/src/components/Charts/StackedLineChart/index.tsx @@ -3,7 +3,6 @@ import { Chart, ChartModel, ChartModelParams } from 'components/Charts/ChartMode import { StackedAreaSeriesOptions } from 'components/Charts/StackedLineChart/stacked-area-series/options' import { StackedAreaSeries } from 'components/Charts/StackedLineChart/stacked-area-series/stacked-area-series' import { getProtocolColor } from 'graphql/data/util' -import { useTheme } from 'lib/styled-components' import { CustomStyleOptions, DeepPartial, @@ -14,6 +13,7 @@ import { WhitespaceData, } from 'lightweight-charts' import { useMemo } from 'react' +import { useTheme } from 'styled-components' import { PriceSource } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' export interface StackedLineData extends WhitespaceData { diff --git a/apps/web/src/components/Charts/TimeSelector.tsx b/apps/web/src/components/Charts/TimeSelector.tsx index 4563ec74b7e..9a8448e5c2f 100644 --- a/apps/web/src/components/Charts/TimeSelector.tsx +++ b/apps/web/src/components/Charts/TimeSelector.tsx @@ -3,7 +3,7 @@ import { MEDIUM_MEDIA_BREAKPOINT } from 'components/Tokens/constants' import { TimePeriod } from 'graphql/data/util' import { atom } from 'jotai' import { useAtomValue } from 'jotai/utils' -import styled from 'lib/styled-components' +import styled from 'styled-components' export const refitChartContentAtom = atom<(() => void) | undefined>(undefined) const DEFAULT_TIME_SELECTOR_OPTIONS = ORDERED_TIMES.map((time: TimePeriod) => ({ time, display: DISPLAYS[time] })) diff --git a/apps/web/src/components/Charts/VolumeChart/index.tsx b/apps/web/src/components/Charts/VolumeChart/index.tsx index 10a8df85615..49391813d2e 100644 --- a/apps/web/src/components/Charts/VolumeChart/index.tsx +++ b/apps/web/src/components/Charts/VolumeChart/index.tsx @@ -10,8 +10,8 @@ import { useHeaderDateFormatter } from 'components/Charts/hooks' import { BIPS_BASE } from 'constants/misc' import { TimePeriod, toHistoryDuration } from 'graphql/data/util' import { t } from 'i18n' -import { useTheme } from 'lib/styled-components' import { useMemo } from 'react' +import { useTheme } from 'styled-components' import { ThemedText } from 'theme/components' import { HistoryDuration } from 'uniswap/src/data/graphql/uniswap-data-api/__generated__/types-and-hooks' import { NumberType, useFormatter } from 'utils/formatNumbers' diff --git a/apps/web/src/components/Column/index.tsx b/apps/web/src/components/Column/index.tsx index 9d18001eede..a75d9d60837 100644 --- a/apps/web/src/components/Column/index.tsx +++ b/apps/web/src/components/Column/index.tsx @@ -1,7 +1,6 @@ -import styled from 'lib/styled-components' +import styled from 'styled-components' import { Gap } from 'theme' -/** @deprecated Please use `Flex` from `ui/src` going forward */ export const Column = styled.div<{ gap?: Gap | string flex?: string @@ -12,8 +11,6 @@ export const Column = styled.div<{ gap: ${({ gap, theme }) => (gap && theme.grids[gap as Gap]) || gap}; ${({ flex }) => flex && `flex: ${flex};`} ` - -/** @deprecated Please use `Flex` from `ui/src` going forward */ export const ColumnCenter = styled(Column)` width: 100%; align-items: center; diff --git a/apps/web/src/components/Common/index.tsx b/apps/web/src/components/Common/index.tsx index 150327fb54c..2def1d4a25c 100644 --- a/apps/web/src/components/Common/index.tsx +++ b/apps/web/src/components/Common/index.tsx @@ -1,4 +1,4 @@ -import { css } from 'lib/styled-components' +import { css } from 'styled-components' export const ScrollBarStyles = css<{ $isHorizontalScroll?: boolean }>` // Firefox scrollbar styling diff --git a/apps/web/src/components/ConfirmSwapModal/Head.tsx b/apps/web/src/components/ConfirmSwapModal/Head.tsx index b052b9df029..f8b485e7b26 100644 --- a/apps/web/src/components/ConfirmSwapModal/Head.tsx +++ b/apps/web/src/components/ConfirmSwapModal/Head.tsx @@ -1,7 +1,17 @@ +import GetHelpButton from 'components/Button/GetHelp' import { ConfirmModalState } from 'components/ConfirmSwapModal' -import { GetHelpHeader } from 'components/Modal/GetHelpHeader' +import Row from 'components/Row' import { Trans } from 'i18n' +import { X } from 'react-feather' +import styled from 'styled-components' +import { ClickableStyle, ThemedText } from 'theme/components' +import { FadePresence } from 'theme/components/FadePresence' +const CloseIcon = styled(X)<{ onClick: () => void }>` + color: ${({ theme }) => theme.neutral1}; + cursor: pointer; + ${ClickableStyle} +` export function SwapHead({ onDismiss, isLimitTrade, @@ -11,12 +21,21 @@ export function SwapHead({ isLimitTrade: boolean confirmModalState: ConfirmModalState }) { - const swapTitle = isLimitTrade ? : return ( - + + {confirmModalState === ConfirmModalState.REVIEWING && ( + + + + {isLimitTrade ? : } + + + + )} + + + + + ) } diff --git a/apps/web/src/components/ConfirmSwapModal/Modal.tsx b/apps/web/src/components/ConfirmSwapModal/Modal.tsx index cdf26104a70..821629c4f81 100644 --- a/apps/web/src/components/ConfirmSwapModal/Modal.tsx +++ b/apps/web/src/components/ConfirmSwapModal/Modal.tsx @@ -2,9 +2,9 @@ import { InterfaceModalName } from '@uniswap/analytics-events' import { AutoColumn } from 'components/Column' import { ConfirmModalState } from 'components/ConfirmSwapModal' import Modal from 'components/Modal' -import styled from 'lib/styled-components' import { PropsWithChildren, useRef } from 'react' import { animated, easings, useSpring } from 'react-spring' +import styled from 'styled-components' import { TRANSITION_DURATIONS } from 'theme/styles' import Trace from 'uniswap/src/features/telemetry/Trace' import useResizeObserver from 'use-resize-observer' diff --git a/apps/web/src/components/ConfirmSwapModal/Pending.tsx b/apps/web/src/components/ConfirmSwapModal/Pending.tsx index 44bc3149000..dbb545556c0 100644 --- a/apps/web/src/components/ConfirmSwapModal/Pending.tsx +++ b/apps/web/src/components/ConfirmSwapModal/Pending.tsx @@ -13,12 +13,12 @@ import { useAccount } from 'hooks/useAccount' import { SwapResult } from 'hooks/useSwapCallback' import { useUnmountingAnimation } from 'hooks/useUnmountingAnimation' import { Trans, t } from 'i18n' -import styled, { css } from 'lib/styled-components' import { ReactNode, useMemo, useRef } from 'react' import { InterfaceTrade, TradeFillType } from 'state/routing/types' import { isLimitTrade, isUniswapXTradeType } from 'state/routing/utils' import { useOrder } from 'state/signatures/hooks' import { useIsTransactionConfirmed, useSwapTransactionStatus } from 'state/transactions/hooks' +import styled, { css } from 'styled-components' import { ExternalLink } from 'theme/components' import { AnimationType } from 'theme/components/FadePresence' import { ThemedText } from 'theme/components/text' diff --git a/apps/web/src/components/ConfirmSwapModal/ProgressIndicator.tsx b/apps/web/src/components/ConfirmSwapModal/ProgressIndicator.tsx index fc1c1c2f270..2e1cb248d25 100644 --- a/apps/web/src/components/ConfirmSwapModal/ProgressIndicator.tsx +++ b/apps/web/src/components/ConfirmSwapModal/ProgressIndicator.tsx @@ -10,12 +10,12 @@ import { useColor } from 'hooks/useColor' import { SwapResult } from 'hooks/useSwapCallback' import { t } from 'i18n' import useNativeCurrency from 'lib/hooks/useNativeCurrency' -import styled, { useTheme } from 'lib/styled-components' import { useEffect, useMemo, useState } from 'react' import { InterfaceTrade } from 'state/routing/types' import { isLimitTrade, isUniswapXSwapTrade, isUniswapXTradeType } from 'state/routing/utils' import { useOrder } from 'state/signatures/hooks' import { useIsTransactionConfirmed, useSwapTransactionStatus } from 'state/transactions/hooks' +import styled, { useTheme } from 'styled-components' import { colors } from 'theme/colors' import { Divider } from 'theme/components' import { UniswapXOrderStatus } from 'types/uniswapx' diff --git a/apps/web/src/components/ConfirmSwapModal/Step.tsx b/apps/web/src/components/ConfirmSwapModal/Step.tsx index f0d398cd26b..789d5e4e397 100644 --- a/apps/web/src/components/ConfirmSwapModal/Step.tsx +++ b/apps/web/src/components/ConfirmSwapModal/Step.tsx @@ -2,8 +2,8 @@ import Column from 'components/Column' import { CheckMark } from 'components/Icons/CheckMark' import { LoaderV3 } from 'components/Icons/LoadingSpinner' import Row, { RowBetween } from 'components/Row' -import styled, { Keyframes, keyframes } from 'lib/styled-components' import { ReactElement, useEffect, useState } from 'react' +import styled, { Keyframes, keyframes } from 'styled-components' import { ExternalLink, ThemedText } from 'theme/components' export interface StepDetails { diff --git a/apps/web/src/components/ConfirmSwapModal/TradeSummary.tsx b/apps/web/src/components/ConfirmSwapModal/TradeSummary.tsx index 678b29c37fb..91971afa4fb 100644 --- a/apps/web/src/components/ConfirmSwapModal/TradeSummary.tsx +++ b/apps/web/src/components/ConfirmSwapModal/TradeSummary.tsx @@ -1,8 +1,8 @@ import CurrencyLogo from 'components/Logo/CurrencyLogo' import Row from 'components/Row' -import { useTheme } from 'lib/styled-components' import { ArrowRight } from 'react-feather' import { InterfaceTrade } from 'state/routing/types' +import { useTheme } from 'styled-components' import { ThemedText } from 'theme/components' import { useFormatter } from 'utils/formatNumbers' diff --git a/apps/web/src/components/ConfirmSwapModal/__snapshots__/Head.test.tsx.snap b/apps/web/src/components/ConfirmSwapModal/__snapshots__/Head.test.tsx.snap index 86623136beb..3ce8c20dd60 100644 --- a/apps/web/src/components/ConfirmSwapModal/__snapshots__/Head.test.tsx.snap +++ b/apps/web/src/components/ConfirmSwapModal/__snapshots__/Head.test.tsx.snap @@ -2,13 +2,72 @@ exports[`ConfirmSwapModal/Head should render correctly for a Limit order 1`] = ` - .c2 { + .c0 { + box-sizing: border-box; + margin: 0; + min-width: 0; + width: 100%; +} + +.c2 { box-sizing: border-box; margin: 0; min-width: 0; } +.c1 { + width: 100%; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + padding: 0; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-pack: start; + -webkit-justify-content: flex-start; + -ms-flex-pack: start; + justify-content: flex-start; +} + .c3 { + width: 100%; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + padding: 0; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-pack: left; + -webkit-justify-content: left; + -ms-flex-pack: left; + justify-content: left; +} + +.c6 { + width: 100%; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + padding: 0; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-pack: right; + -webkit-justify-content: right; + -ms-flex-pack: right; + justify-content: right; + gap: 10px; +} + +.c9 { width: 100%; display: -webkit-box; display: -webkit-flex; @@ -26,25 +85,28 @@ exports[`ConfirmSwapModal/Head should render correctly for a Limit order 1`] = ` gap: 4px; } -.c4 { +.c5 { color: #222222; - cursor: pointer; - -webkit-text-decoration: none; - text-decoration: none; - cursor: pointer; - -webkit-transition-duration: 125ms; - transition-duration: 125ms; + -webkit-letter-spacing: -0.01em; + -moz-letter-spacing: -0.01em; + -ms-letter-spacing: -0.01em; + letter-spacing: -0.01em; } -.c4:hover { - opacity: 0.6; +.c4 { + -webkit-transition: display 250ms ease-in-out, -webkit-transform 250ms ease-in-out; + -webkit-transition: display 250ms ease-in-out, transform 250ms ease-in-out; + transition: display 250ms ease-in-out, transform 250ms ease-in-out; + -webkit-animation: iCPeaJ 250ms ease-in-out forwards; + animation: iCPeaJ 250ms ease-in-out forwards; } -.c4:active { - opacity: 0.4; +.c4.exiting { + -webkit-animation: bbnGid 250ms ease-in-out; + animation: bbnGid 250ms ease-in-out; } -.c0 { +.c7 { -webkit-text-decoration: none; text-decoration: none; cursor: pointer; @@ -55,15 +117,15 @@ exports[`ConfirmSwapModal/Head should render correctly for a Limit order 1`] = ` font-weight: 500; } -.c0:hover { +.c7:hover { opacity: 0.6; } -.c0:active { +.c7:active { opacity: 0.4; } -.c1 { +.c8 { width: -webkit-fit-content; width: -moz-fit-content; width: fit-content; @@ -77,16 +139,34 @@ exports[`ConfirmSwapModal/Head should render correctly for a Limit order 1`] = ` stroke: none; } -.c1:hover { +.c8:hover { background: #22222212; color: #222222; opacity: unset; } -.c1:hover path { +.c8:hover path { fill: #222222; } +.c10 { + color: #222222; + cursor: pointer; + -webkit-text-decoration: none; + text-decoration: none; + cursor: pointer; + -webkit-transition-duration: 125ms; + transition-duration: 125ms; +} + +.c10:hover { + opacity: 0.6; +} + +.c10:active { + opacity: 0.4; +} + @@ -94,29 +174,33 @@ exports[`ConfirmSwapModal/Head should render correctly for a Limit order 1`] = ` class=" t_light _dsp_contents is_Theme" >
- - Review limit - +
+ Review limit +
+
- .c2 { + .c0 { + box-sizing: border-box; + margin: 0; + min-width: 0; + width: 100%; +} + +.c2 { box-sizing: border-box; margin: 0; min-width: 0; } +.c1 { + width: 100%; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + padding: 0; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-pack: start; + -webkit-justify-content: flex-start; + -ms-flex-pack: start; + justify-content: flex-start; +} + .c3 { + width: 100%; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + padding: 0; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-pack: left; + -webkit-justify-content: left; + -ms-flex-pack: left; + justify-content: left; +} + +.c6 { + width: 100%; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + padding: 0; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-pack: right; + -webkit-justify-content: right; + -ms-flex-pack: right; + justify-content: right; + gap: 10px; +} + +.c9 { width: 100%; display: -webkit-box; display: -webkit-flex; @@ -192,25 +335,28 @@ exports[`ConfirmSwapModal/Head should render correctly for a classic swap 1`] = gap: 4px; } -.c4 { +.c5 { color: #222222; - cursor: pointer; - -webkit-text-decoration: none; - text-decoration: none; - cursor: pointer; - -webkit-transition-duration: 125ms; - transition-duration: 125ms; + -webkit-letter-spacing: -0.01em; + -moz-letter-spacing: -0.01em; + -ms-letter-spacing: -0.01em; + letter-spacing: -0.01em; } -.c4:hover { - opacity: 0.6; +.c4 { + -webkit-transition: display 250ms ease-in-out, -webkit-transform 250ms ease-in-out; + -webkit-transition: display 250ms ease-in-out, transform 250ms ease-in-out; + transition: display 250ms ease-in-out, transform 250ms ease-in-out; + -webkit-animation: iCPeaJ 250ms ease-in-out forwards; + animation: iCPeaJ 250ms ease-in-out forwards; } -.c4:active { - opacity: 0.4; +.c4.exiting { + -webkit-animation: bbnGid 250ms ease-in-out; + animation: bbnGid 250ms ease-in-out; } -.c0 { +.c7 { -webkit-text-decoration: none; text-decoration: none; cursor: pointer; @@ -221,15 +367,15 @@ exports[`ConfirmSwapModal/Head should render correctly for a classic swap 1`] = font-weight: 500; } -.c0:hover { +.c7:hover { opacity: 0.6; } -.c0:active { +.c7:active { opacity: 0.4; } -.c1 { +.c8 { width: -webkit-fit-content; width: -moz-fit-content; width: fit-content; @@ -243,16 +389,34 @@ exports[`ConfirmSwapModal/Head should render correctly for a classic swap 1`] = stroke: none; } -.c1:hover { +.c8:hover { background: #22222212; color: #222222; opacity: unset; } -.c1:hover path { +.c8:hover path { fill: #222222; } +.c10 { + color: #222222; + cursor: pointer; + -webkit-text-decoration: none; + text-decoration: none; + cursor: pointer; + -webkit-transition-duration: 125ms; + transition-duration: 125ms; +} + +.c10:hover { + opacity: 0.6; +} + +.c10:active { + opacity: 0.4; +} + @@ -260,29 +424,33 @@ exports[`ConfirmSwapModal/Head should render correctly for a classic swap 1`] = class=" t_light _dsp_contents is_Theme" >
- - Review swap - +
+ Review swap +
+
theme.surface1}; @@ -49,10 +49,6 @@ const StyledButton = styled(ThemeButton)<{ $color?: keyof DefaultTheme }>` border-radius: 12px; ` -const DialogHeader = styled(GetHelpHeader)` - padding: 4px 0px; -` - export enum DialogButtonType { Primary = 'primary', Error = 'error', @@ -155,7 +151,10 @@ export function Dialog(props: DialogProps) { return ( - + + + + diff --git a/apps/web/src/components/Dialog/__snapshots__/Dialog.test.tsx.snap b/apps/web/src/components/Dialog/__snapshots__/Dialog.test.tsx.snap index fb2ac6f8b3f..92fe5e2910e 100644 --- a/apps/web/src/components/Dialog/__snapshots__/Dialog.test.tsx.snap +++ b/apps/web/src/components/Dialog/__snapshots__/Dialog.test.tsx.snap @@ -1,13 +1,40 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[` renders different button types 1`] = ` -.c8 { +.c5 { box-sizing: border-box; margin: 0; min-width: 0; + width: 100%; + padding: 4px 0px; } .c9 { + box-sizing: border-box; + margin: 0; + min-width: 0; +} + +.c6 { + width: 100%; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + padding: 0; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-pack: end; + -webkit-justify-content: end; + -ms-flex-pack: end; + justify-content: end; + padding: 4px 0px; + gap: 10px; +} + +.c10 { width: 100%; display: -webkit-box; display: -webkit-flex; @@ -25,7 +52,7 @@ exports[` renders different button types 1`] = ` gap: 4px; } -.c18 { +.c19 { width: 100%; display: -webkit-box; display: -webkit-flex; @@ -43,7 +70,7 @@ exports[` renders different button types 1`] = ` gap: 12px; } -.c14 { +.c15 { color: #222222; -webkit-letter-spacing: -0.01em; -moz-letter-spacing: -0.01em; @@ -51,7 +78,7 @@ exports[` renders different button types 1`] = ` letter-spacing: -0.01em; } -.c16 { +.c17 { color: #7D7D7D; -webkit-letter-spacing: -0.01em; -moz-letter-spacing: -0.01em; @@ -59,7 +86,7 @@ exports[` renders different button types 1`] = ` letter-spacing: -0.01em; } -.c10 { +.c11 { color: #222222; cursor: pointer; -webkit-text-decoration: none; @@ -69,15 +96,15 @@ exports[` renders different button types 1`] = ` transition-duration: 125ms; } -.c10:hover { +.c11:hover { opacity: 0.6; } -.c10:active { +.c11:active { opacity: 0.4; } -.c6 { +.c7 { -webkit-text-decoration: none; text-decoration: none; cursor: pointer; @@ -88,15 +115,15 @@ exports[` renders different button types 1`] = ` font-weight: 500; } -.c6:hover { +.c7:hover { opacity: 0.6; } -.c6:active { +.c7:active { opacity: 0.4; } -.c22 { +.c23 { background-color: transparent; bottom: 0; border-radius: inherit; @@ -110,7 +137,7 @@ exports[` renders different button types 1`] = ` width: 100%; } -.c19 { +.c20 { -webkit-align-items: center; -webkit-box-align: center; -ms-flex-align: center; @@ -145,30 +172,30 @@ exports[` renders different button types 1`] = ` user-select: none; } -.c19:active .c21 { +.c20:active .c22 { background-color: #B8C0DC3d; } -.c19:focus .c21 { +.c20:focus .c22 { background-color: #B8C0DC3d; } -.c19:hover .c21 { +.c20:hover .c22 { background-color: #98A1C014; } -.c19:disabled { +.c20:disabled { cursor: default; opacity: 0.6; } -.c19:disabled:active .c21, -.c19:disabled:focus .c21, -.c19:disabled:hover .c21 { +.c20:disabled:active .c22, +.c20:disabled:focus .c22, +.c20:disabled:hover .c22 { background-color: transparent; } -.c23 { +.c24 { -webkit-align-items: center; -webkit-box-align: center; -ms-flex-align: center; @@ -203,29 +230,53 @@ exports[` renders different button types 1`] = ` user-select: none; } -.c23:active .c21 { +.c24:active .c22 { background-color: #B8C0DC3d; } -.c23:focus .c21 { +.c24:focus .c22 { background-color: #B8C0DC3d; } -.c23:hover .c21 { +.c24:hover .c22 { background-color: #98A1C014; } -.c23:disabled { +.c24:disabled { cursor: default; opacity: 0.6; } -.c23:disabled:active .c21, -.c23:disabled:focus .c21, -.c23:disabled:hover .c21 { +.c24:disabled:active .c22, +.c24:disabled:focus .c22, +.c24:disabled:hover .c22 { background-color: transparent; } +.c8 { + width: -webkit-fit-content; + width: -moz-fit-content; + width: fit-content; + border-radius: 16px; + padding: 4px 8px; + font-size: 14px; + font-weight: 485; + line-height: 20px; + background: #F9F9F9; + color: #7D7D7D; + stroke: none; +} + +.c8:hover { + background: #22222212; + color: #222222; + opacity: unset; +} + +.c8:hover path { + fill: #222222; +} + .c2 { display: -webkit-box; display: -webkit-flex; @@ -241,7 +292,7 @@ exports[` renders different button types 1`] = ` gap: 24px; } -.c11 { +.c12 { display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; @@ -256,7 +307,7 @@ exports[` renders different button types 1`] = ` gap: 16px; } -.c13 { +.c14 { display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; @@ -322,30 +373,6 @@ exports[` renders different button types 1`] = ` border-radius: 20px; } -.c7 { - width: -webkit-fit-content; - width: -moz-fit-content; - width: fit-content; - border-radius: 16px; - padding: 4px 8px; - font-size: 14px; - font-weight: 485; - line-height: 20px; - background: #F9F9F9; - color: #7D7D7D; - stroke: none; -} - -.c7:hover { - background: #22222212; - color: #222222; - opacity: unset; -} - -.c7:hover path { - fill: #222222; -} - .c4 { background-color: #FFFFFF; outline: 1px solid #22222212; @@ -354,7 +381,7 @@ exports[` renders different button types 1`] = ` width: 100%; } -.c12 { +.c13 { display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; @@ -373,14 +400,14 @@ exports[` renders different button types 1`] = ` border-radius: 12px; } -.c15 { +.c16 { font-size: 24px; line-height: 32px; text-align: center; font-weight: 500; } -.c17 { +.c18 { font-size: 16px; font-weight: 500; line-height: 24px; @@ -391,7 +418,7 @@ exports[` renders different button types 1`] = ` text-align: center; } -.c20 { +.c21 { display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; @@ -404,10 +431,6 @@ exports[` renders different button types 1`] = ` border-radius: 12px; } -.c5 { - padding: 4px 0px; -} - @media screen and (max-width:640px) { .c0[data-reach-dialog-overlay] { -webkit-align-items: flex-end; @@ -478,88 +501,85 @@ exports[` renders different button types 1`] = ` class="c2 c3 c4" >
- + + + Get help +
+ + + + +
Mock Icon
Mock Title
Mock Description @@ -571,24 +591,24 @@ exports[` renders different button types 1`] = `