ci(release): publish latest release

parent 66754ca0

Too many changes to show.

To preserve performance only 1000 of 1000+ files are displayed.

......@@ -3,6 +3,7 @@ ignores: [
'@graphql-codegen/*',
'@commitlint/*',
'i18next',
'moti',
# Dependencies that depcheck thinks are missing but are actually present or never used
'@yarnpkg/core',
'@yarnpkg/cli',
......
......@@ -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
......
We are back with some new new updates! Here’s the latest:
UniswapX is live: We’ve integrated the UniswapX protocol, which aggregates liquidity across onchain and offchain sources for better quotes.
Easy import to Uniswap Extension: Onboard onto our new Chrome extension wallet easily by scanning a QR code with your Uniswap Mobile App.
Transaction Details: Press anything on the Activity Screen and see more robust details about any of your transactions (swaps, sends, NFTs, etc).
Other changes:
- Onboarding improvements
- Adds fallback support method for opening the side panel on chrome failure
- Various bug fixes and performance improvements
mobile/1.31.1
\ No newline at end of file
extension/1.2.1
\ No newline at end of file
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",
]
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: {},
}
# 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
# 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.
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',
}
})
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: "<rootDir>/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',
],
}
{
"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.34.0",
"@uniswap/sdk-core": "5.3.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",
"concurrently": "^8.0.1",
"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:circular": "concurrently \"../../scripts/check-circular-imports.sh ./src/sidebar/sidebar.tsx 1\" \"../../scripts/check-circular-imports.sh ./src/onboarding/onboarding.tsx 1\"",
"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"
}
}
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%;
}
}
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(<OnboardingApp />)
})
})
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 { 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 { 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 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: <UnsupportedBrowserScreen />,
}
const allRoutes = [
{
path: '',
element: <IntroScreen />,
},
{
path: OnboardingRoutes.UnsupportedBrowser,
element: <UnsupportedBrowserScreen />,
},
{
path: OnboardingRoutes.Create,
element: (
<OnboardingStepsProvider
key={OnboardingRoutes.Create}
steps={{
[CreateOnboardingSteps.Password]: <PasswordCreate />,
[CreateOnboardingSteps.ViewMnemonic]: <ViewMnemonic />,
[CreateOnboardingSteps.TestMnemonic]: <TestMnemonic />,
[CreateOnboardingSteps.Naming]: <NameWallet />,
[CreateOnboardingSteps.Complete]: <Complete flow={ExtensionOnboardingFlow.New} />,
}}
/>
),
},
{
path: OnboardingRoutes.Import,
element: (
<OnboardingStepsProvider
key={OnboardingRoutes.Import}
steps={{
[ImportOnboardingSteps.Mnemonic]: <ImportMnemonic />,
[ImportOnboardingSteps.Password]: <PasswordImport flow={ExtensionOnboardingFlow.Import} />,
[ImportOnboardingSteps.Select]: <SelectWallets flow={ExtensionOnboardingFlow.Import} />,
[ImportOnboardingSteps.Complete]: <Complete flow={ExtensionOnboardingFlow.Import} />,
}}
/>
),
},
{
path: OnboardingRoutes.Scan,
element: <ScantasticFlow key={OnboardingRoutes.Scan} />,
},
{
path: OnboardingRoutes.ResetScan,
element: <ScantasticFlow key={OnboardingRoutes.ResetScan} isResetting />,
},
{
path: OnboardingRoutes.Reset,
element: (
<OnboardingStepsProvider
key={OnboardingRoutes.Reset}
isResetting
steps={{
[ResetSteps.Mnemonic]: <ImportMnemonic />,
[ResetSteps.Password]: <PasswordImport flow={ExtensionOnboardingFlow.Import} />,
[ResetSteps.Select]: <SelectWallets flow={ExtensionOnboardingFlow.Import} />,
[ResetSteps.Complete]: <ResetComplete />,
}}
/>
),
},
]
const router = sentryCreateHashRouter([
{
path: `/${TopLevelRoutes.Onboarding}`,
element: <OnboardingWrapper />,
errorElement: <ErrorElement />,
children: !supportsSidePanel ? [unsupportedRoute] : allRoutes,
},
])
function ScantasticFlow({ isResetting = false }: { isResetting?: boolean }): JSX.Element {
return (
<OnboardingStepsProvider
ContainerComponent={ScantasticContextProvider}
isResetting={isResetting}
steps={{
[ScanOnboardingSteps.Scan]: <ScanToOnboard />,
[ScanOnboardingSteps.OTP]: <OTPInput />,
[ScanOnboardingSteps.Password]: <PasswordImport allowBack={false} flow={ExtensionOnboardingFlow.Scantastic} />,
[ScanOnboardingSteps.Select]: <SelectWallets flow={ExtensionOnboardingFlow.Scantastic} />,
[ScanOnboardingSteps.Complete]: isResetting ? (
<ResetComplete />
) : (
<Complete flow={ExtensionOnboardingFlow.Scantastic} />
),
}}
/>
)
}
/**
* 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<void> {
await initExtensionAnalytics()
sendAnalyticsEvent(ExtensionEventName.OnboardingLoad)
}
initAndLogLoad().catch(() => undefined)
}, [])
return (
<Trace>
<PersistGate persistor={getReduxPersistor()}>
<ExtensionStatsigProvider>
<I18nextProvider i18n={i18n}>
<SharedProvider reduxStore={getReduxStore()}>
<ErrorBoundary>
<GraphqlProvider>
<LocalizationContextProvider>
<UnitagUpdaterContextProvider>
<PrimaryAppInstanceDebuggerLazy />
<RouterProvider router={router} />
</UnitagUpdaterContextProvider>
</LocalizationContextProvider>
</GraphqlProvider>
</ErrorBoundary>
</SharedProvider>
</I18nextProvider>
</ExtensionStatsigProvider>
</PersistGate>
</Trace>
)
}
import '@tamagui/core/reset.css'
import 'src/app/Global.css'
import { useEffect } from 'react'
import { I18nextProvider, useTranslation } from 'react-i18next'
import { useDispatch } from 'react-redux'
import { 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 { TraceUserProperties } from 'src/app/components/Trace/TraceUserProperties'
import { DappContextProvider } from 'src/app/features/dapp/DappContext'
import { SentryAppNameTag, initializeSentry, sentryCreateHashRouter } from 'src/app/sentry'
import { initExtensionAnalytics } from 'src/app/utils/analytics'
import { getLocalUserId } from 'src/app/utils/storage'
import { getReduxPersistor, getReduxStore } from 'src/store/store'
import { Button, Flex, Image, Text } from 'ui/src'
import { CHROME_LOGO, UNISWAP_LOGO } from 'ui/src/assets'
import { iconSizes, spacing } from 'ui/src/theme'
import Trace from 'uniswap/src/features/telemetry/Trace'
import { ElementName } from 'uniswap/src/features/telemetry/constants'
import { UnitagUpdaterContextProvider } from 'uniswap/src/features/unitags/context'
import i18n from 'uniswap/src/i18n/i18n'
import { ExtensionScreens } from 'uniswap/src/types/screens/extension'
import { logger } from 'utilities/src/logger/logger'
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.Popup, userId)
})
.catch((error) => {
logger.error(error, {
tags: { file: 'PopupApp.tsx', function: 'getLocalUserId' },
})
})
const router = sentryCreateHashRouter([
{
path: '',
element: <PopupContent />,
errorElement: <ErrorElement />,
},
])
function PopupContent(): JSX.Element {
const { t } = useTranslation()
const dispatch = useDispatch()
useEffect(() => {
dispatch(syncAppWithDeviceLanguage())
}, [dispatch])
const searchParams = new URLSearchParams(window.location.search)
const tabId = searchParams.get('tabId')
const windowId = searchParams.get('windowId')
const tabIdNumber = tabId ? Number(tabId) : undefined
const windowIdNumber = windowId ? Number(windowId) : undefined
return (
<Trace logImpression screen={ExtensionScreens.PopupOpenExtension}>
<Flex fill gap="$spacing16" height="100%" px="$spacing24" py="$spacing24">
<Flex row>
<Flex position="relative">
<Image height={iconSizes.icon40} source={UNISWAP_LOGO} width={iconSizes.icon40} />
<Flex
backgroundColor="$surface1"
borderColor="$surface3"
borderRadius={6}
borderWidth={1}
bottom={-spacing.spacing4}
p="$spacing2"
position="absolute"
right={-spacing.spacing4}
>
<Image height={iconSizes.icon12} source={CHROME_LOGO} width={iconSizes.icon12} />
</Flex>
</Flex>
</Flex>
<Flex gap="$spacing4">
<Text color="$neutral1" variant="subheading1">
{t('extension.popup.chrome.title')}
</Text>
<Text color="$neutral2" variant="body2">
{t('extension.popup.chrome.description')}
</Text>
</Flex>
<Flex fill />
<Trace logPress element={ElementName.ExtensionPopupOpenButton}>
<Button
theme="primary"
width="100%"
onPress={async () => {
if (windowIdNumber) {
// eslint-disable-next-line security/detect-non-literal-fs-filename
await chrome.sidePanel.open({ tabId: tabIdNumber, windowId: windowIdNumber })
window.close()
}
}}
>
{t('extension.popup.chrome.button')}
</Button>
</Trace>
</Flex>
</Trace>
)
}
// TODO WALL-4313 - Backup for some broken chrome.sidePanel.open functionality
// Consider removing this once the issue is resolved or leaving as fallback
export default function PopupApp(): JSX.Element {
// initialize analytics on load
useEffect(() => {
initExtensionAnalytics().catch(() => undefined)
}, [])
return (
<Trace>
<PersistGate persistor={getReduxPersistor()}>
<ExtensionStatsigProvider>
<I18nextProvider i18n={i18n}>
<SharedProvider reduxStore={getReduxStore()}>
<ErrorBoundary>
<GraphqlProvider>
<LocalizationContextProvider>
<UnitagUpdaterContextProvider>
<TraceUserProperties />
<DappContextProvider>
<RouterProvider router={router} />
</DappContextProvider>
</UnitagUpdaterContextProvider>
</LocalizationContextProvider>
</GraphqlProvider>
</ErrorBoundary>
</SharedProvider>
</I18nextProvider>
</ExtensionStatsigProvider>
</PersistGate>
</Trace>
)
}
import '@tamagui/core/reset.css'
import 'src/app/Global.css'
import { useEffect, useRef, useState } from 'react'
import { I18nextProvider } from 'react-i18next'
import { useDispatch } from 'react-redux'
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 } 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: <SidebarWrapper />,
errorElement: <ErrorElement />,
children: [
{
path: '',
element: <MainContent />,
},
{
path: AppRoutes.AccountSwitcher,
element: <AccountSwitcherScreen />,
},
{
path: AppRoutes.Settings,
element: <SettingsScreenWrapper />,
children: [
{
path: '',
element: <SettingsScreen />,
},
{
path: SettingsRoutes.ChangePassword,
element: <SettingsChangePasswordScreen />,
},
isDevEnv()
? {
path: SettingsRoutes.DevMenu,
element: <DevMenuScreen />,
}
: {},
{
path: SettingsRoutes.ViewRecoveryPhrase,
element: <SettingsViewRecoveryPhraseScreen />,
},
{
path: SettingsRoutes.RemoveRecoveryPhrase,
children: [
{
path: RemoveRecoveryPhraseRoutes.Wallets,
element: <RemoveRecoveryPhraseWallets />,
},
{
path: RemoveRecoveryPhraseRoutes.Verify,
element: <RemoveRecoveryPhraseVerify />,
},
],
},
{
path: SettingsRoutes.Privacy,
element: <SettingsPrivacyScreen />,
},
],
},
{
path: AppRoutes.Transfer,
element: <TransferFlowScreen />,
},
{
path: AppRoutes.Swap,
element: <SwapFlowScreen />,
},
{
path: AppRoutes.Receive,
element: <ReceiveScreen />,
},
],
},
])
const PORT_PING_INTERVAL = 5 * ONE_SECOND_MS
function useDappRequestPortListener(): void {
const dispatch = useDispatch()
const [currentPortChannel, setCurrentPortChannel] = useState<DappBackgroundPortChannel | undefined>()
const [windowId, setWindowId] = useState<string | undefined>()
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 = useDispatch()
useDappRequestPortListener()
useEffect(() => {
dispatch(syncAppWithDeviceLanguage())
}, [dispatch])
return (
<>
<ScrollRestoration />
<WebNavigation />
</>
)
}
/**
* 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 (
<Trace>
<PersistGate persistor={getReduxPersistor()}>
<ExtensionStatsigProvider>
<I18nextProvider i18n={i18n}>
<SharedProvider reduxStore={getReduxStore()}>
<ErrorBoundary>
<GraphqlProvider>
<LocalizationContextProvider>
<UnitagUpdaterContextProvider>
<TraceUserProperties />
<DappContextProvider>
<PrimaryAppInstanceDebuggerLazy />
<RouterProvider router={router} />
</DappContextProvider>
</UnitagUpdaterContextProvider>
</LocalizationContextProvider>
</GraphqlProvider>
</ErrorBoundary>
</SharedProvider>
</I18nextProvider>
</ExtensionStatsigProvider>
</PersistGate>
</Trace>
)
}
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<StatsigUser> {
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 (
<StatsigProvider options={options} sdkKey={DUMMY_STATSIG_SDK_KEY} user={nonNullUser} waitForInitialization={false}>
{children}
</StatsigProvider>
)
}
export async function initStatSigForBrowserScripts(): Promise<void> {
await Statsig.initialize(DUMMY_STATSIG_SDK_KEY, await getStatsigUser(), {
api: uniswapUrls.statsigProxyUrl,
environment: {
tier: getStatsigEnvironmentTier(),
},
})
}
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<unknown>): JSX.Element {
const apolloClient = usePersistedApolloClient({
storageWrapper: localStorage,
maxCacheSizeInBytes: MAX_CACHE_SIZE_IN_BYTES,
})
if (!apolloClient) {
return <></>
}
return <ApolloProvider client={apolloClient}>{children}</ApolloProvider>
}
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 (
<Tooltip delay={20} placement={placement}>
<Tooltip.Trigger>
<Flex grow flex={1}>
{children}
</Flex>
</Tooltip.Trigger>
<Tooltip.Content px="$none" py="$none">
<Flex p="$spacing12">
<Text color="$neutral2" variant="body3">
{t('settings.setting.beta.tooltip')}
</Text>
</Flex>
</Tooltip.Content>
</Tooltip>
)
}
import { PropsWithChildren } from 'react'
import { useRouteError } from 'react-router-dom'
export function ErrorElement({ children }: PropsWithChildren<unknown>): JSX.Element {
const error = useRouteError()
if (!error) {
return <>{children}</>
}
// Need to throw here to propagate to the ErrorBoundary
throw error
}
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<Input, InputProps>(function _Input(
{ large = false, hideInput = false, centered = false, ...rest }: InputProps,
ref,
): JSX.Element {
return (
<TamaguiInput
ref={ref}
backgroundColor={large ? '$surface1' : '$surface2'}
borderColor="$surface3"
borderRadius="$rounded16"
borderWidth={1}
focusStyle={inputStyles.inputFocus}
fontSize={fonts.subheading2.fontSize}
height="auto"
hoverStyle={inputStyles.inputHover}
placeholderTextColor="$neutral3"
px={centered ? '$spacing60' : '$spacing24'}
py={large ? '$spacing20' : '$spacing16'}
secureTextEntry={hideInput}
textAlign={centered ? 'center' : 'left'}
width="100%"
{...rest}
/>
)
})
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(<SeedPhraseRow key={i} startIndex={i + 1} words={mnemonic.slice(i, i + ROW_SIZE)} />)
}
return elements
}, [mnemonic])
return (
<Flex
backgroundColor="$surface2"
borderColor="$surface3"
borderRadius="$rounded20"
borderWidth={1}
gap="$spacing12"
position="relative"
px={px}
py="$spacing24"
width="100%"
>
{rows}
<Flex alignItems="center" left="50%" position="absolute" top={-16} transform="translateX(-50%)">
<CopyButton onCopyPress={onCopyPress} />
</Flex>
</Flex>
)
}
function SeedPhraseRow({ words, startIndex }: { words: string[]; startIndex: number }): JSX.Element {
return (
<Flex fill row>
{words.map((word, index) => (
<SeedPhraseWord key={index} index={index + startIndex} word={word} />
))}
</Flex>
)
}
function SeedPhraseWord({ index, word }: { index: number; word: string }): JSX.Element {
const media = useMedia()
const fontVariant = 'body3'
const gap = media.xxs ? '$spacing4' : '$spacing8'
return (
<Flex fill row flexBasis={0} gap={gap}>
<Text color="$neutral3" minWidth={16} variant={fontVariant}>
{index}
</Text>
<Text variant={fontVariant}>{word}</Text>
</Flex>
)
}
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 ? <StrictMode>{props.children}</StrictMode> : <>{props.children}</>
}
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<TextInput, PasswordInputProps>(function PasswordInput(
{ passwordStrength, hideInput, onToggleHideInput, value, ...inputProps },
ref,
): JSX.Element {
return (
<Flex row alignItems="center" position="relative" width="100%">
<Input ref={ref} hideInput={hideInput} minHeight="$spacing24" pr="$spacing48" value={value} {...inputProps} />
{passwordStrength !== undefined ? (
<StrengthIndicator strength={passwordStrength} />
) : (
onToggleHideInput && (
<Button
backgroundColor="$transparent"
hoverStyle={hoverStyle}
position="absolute"
pressStyle={hoverStyle}
right="$spacing8"
onPress={(): void => onToggleHideInput(!hideInput)}
>
{hideInput ? <Eye {...iconProps} /> : <EyeOff {...iconProps} />}
</Button>
)
)}
</Flex>
)
})
function StrengthIndicator({ strength }: { strength: PasswordStrength }): JSX.Element | null {
if (strength === PasswordStrength.NONE) {
return null
}
const { text, color } = getPasswordStrengthTextAndColor(strength)
return (
<Flex position="absolute" right="$spacing24">
<Text color={color} variant="buttonLabel4">
{text}
</Text>
</Flex>
)
}
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
}
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<void> }): JSX.Element {
const { t } = useTranslation()
const [valueCopied, setValueCopied] = useState(false)
const onPress = async (): Promise<void> => {
await onCopyPress()
setValueCopied(true)
}
return (
<Flex row gap="$spacing24">
<TouchableArea borderRadius="$rounded20" zIndex={zIndices.fixed} onPress={onCopyPress}>
<Flex
row
alignItems="center"
backgroundColor="$surface1"
borderColor={valueCopied ? '$statusSuccess' : '$surface3'}
borderRadius="$rounded20"
borderWidth="$spacing1"
gap="$spacing4"
justifyContent="center"
paddingEnd="$spacing16"
px="$spacing8"
py="$spacing8"
shadowColor="$shadowColor"
shadowOffset={{ width: 0, height: 0 }}
shadowOpacity={0.1}
shadowRadius={4}
// fixed width means no resize on the animation to copied
width={84}
onPress={onPress}
>
<AnimatePresence exitBeforeEnter initial={false}>
{/* note there's various x/y adjustments here due to visual imbalance of icons/text */}
<Flex
key={valueCopied ? 'copy' : 'copied'}
row
alignItems="center"
animateEnterExit="fadeInDownOutDown"
animation="100ms"
gap="$spacing8"
justifyContent="center"
// copied check icon is less wide, content needs to move left to balance
x={valueCopied ? -1 : 0}
>
{valueCopied ? (
// check icon is a bit smaller and to the right
<Check color="$statusSuccess" size={iconSizes.icon12 + 2} x={2} />
) : (
<CopySheets color="$neutral2" size={iconSizes.icon12} />
)}
<Text
color={valueCopied ? '$statusSuccess' : '$neutral2'}
cursor="pointer"
flexShrink={1}
variant="buttonLabel4"
x={valueCopied ? -2 : 0}
y={0.5}
>
{valueCopied ? t('common.button.copied') : t('common.button.copy')}
</Text>
</Flex>
</AnimatePresence>
</Flex>
</TouchableArea>
</Flex>
)
}
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 (
<Flex row alignItems="center" px="$spacing8" py="$spacing4" width="100%">
<TouchableArea onPress={onBackClick ?? navigateBack}>
<Icon color="$neutral2" size="$icon.24" />
</TouchableArea>
{/* 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. */}
<Flex centered fill mr={rightColumn ? '$none' : iconSizes.icon24} py="$spacing8">
{/* // Render empty string if no title to account for Text element added padding for consistent size*/}
<Text variant="subheading1">{title ?? ' '}</Text>
</Flex>
{rightColumn && <Flex>{rightColumn}</Flex>}
</Flex>
)
}
import { Flex } from 'ui/src'
import { LoadingSpinnerInner, LoadingSpinnerOuter } from 'ui/src/components/icons'
const SPINNER_HEIGHT = 80
export function LoadingSpinner(): JSX.Element {
return (
<>
<Flex height={SPINNER_HEIGHT} position="relative" width={80}>
<Flex bottom={0} left={0} position="absolute" right={0} top={0}>
<LoadingSpinnerOuter color="$DEP_brandedAccentSoft" size={80} />
</Flex>
<Flex
bottom={0}
left={0}
position="absolute"
right={0}
style={{ animation: `spin ${SPIN_SPEED_MS}ms linear infinite` }}
top={0}
>
<LoadingSpinnerInner color="$accent1" size={80} />
</Flex>
</Flex>
</>
)
}
const SPIN_SPEED_MS = 1000
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 (
<Flex fill gap="$spacing12">
{new Array(repeat).fill(null).map((_, i, { length }) => (
<WalletSkeleton key={i} opacity={(length - i) / length} />
))}
</Flex>
)
}
function WalletSkeleton({ opacity }: { opacity: number }): JSX.Element {
return (
<Flex
row
alignItems="center"
borderColor="$surface3"
borderRadius="$rounded20"
borderWidth={1}
height={WALLET_PREVIEW_CARD_HEIGHT}
justifyContent="flex-start"
opacity={opacity}
overflow="hidden"
px="$spacing16"
py="$spacing16"
style={{
boxShadow: 'rgba(0, 0, 0, 0.05) 0px 0px 8px',
}}
>
<Flex fill row alignItems="center" gap="$spacing12">
<Flex backgroundColor="$neutral3" borderRadius="$roundedFull" height={32} opacity={0.5} width={32} />
<Flex grow alignItems="flex-start" gap="$spacing2">
<SkeletonBox height={21} width={150} />
<SkeletonBox height={21} width={95} />
</Flex>
</Flex>
</Flex>
)
}
.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%);
}
}
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 <div className="skeleton-box" style={{ width, height, borderRadius }} />
}
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<BottomModalProps>): JSX.Element {
const colors = useSporeColors()
return (
<BottomSheetModal
alignment="bottom"
backgroundColor={colors.surface1.val}
isModalOpen={isOpen}
name={name}
onClose={onDismiss}
>
{showCloseButton && (
<TouchableArea
p="$spacing16"
position="absolute"
right={0}
top={0}
zIndex={zIndices.default}
onPress={onDismiss}
>
<X color="$neutral2" size="$icon.16" />
</TouchableArea>
)}
<Flex alignItems="center" gap="$spacing8" pt="$spacing16">
{icon}
<Flex alignItems="center" gap="$spacing8" pb="$spacing16" pt="$spacing8" px="$spacing4">
<Text color="$neutral1" textAlign="center" variant="subheading2">
{title}
</Text>
<Text color="$neutral2" textAlign="center" variant="body3">
{description}
</Text>
</Flex>
<Button size="medium" theme={buttonTheme} width="100%" onPress={onButtonPress}>
{buttonText}
</Button>
{linkText && linkUrl && (
<Anchor href={linkUrl} lineHeight={16} p="$spacing12" target="_blank" textDecorationLine="none">
<Text color="$neutral2" textAlign="center" variant="buttonLabel4">
{linkText}
</Text>
</Anchor>
)}
</Flex>
</BottomSheetModal>
)
}
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 { maybeEmptyComponent, renderActivityItem, sectionData } = useActivityData({
owner: address,
})
if (maybeEmptyComponent) {
return maybeEmptyComponent
}
return (
<ScrollView showsVerticalScrollIndicator={false} width="100%">
{/* `sectionData` will be either an array of transactions or an array of loading skeletons */}
{(sectionData ?? []).map((item, index) => renderActivityItem({ item, index }))}
</ScrollView>
)
})
import { SharedEventName } from '@uniswap/analytics-events'
import { memo, useCallback } from 'react'
import { useSelector } from 'react-redux'
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'
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 <NftView item={item} owner={owner} onPress={onPress} />
},
[owner],
)
return (
<NftsList
emptyStateStyle={defaultEmptyStyle}
errorStateStyle={defaultEmptyStyle}
owner={owner}
renderNFTItem={renderNFTItem}
/>
)
})
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 = useSelector(selectNftsVisibility)
const hidden = getIsNftHidden({
contractAddress: item.contractAddress,
tokenId: item.tokenId,
isSpam: item.isSpam,
nftVisibility,
})
const itemId = `${item.chain}-${item.contractAddress}-${item.tokenId}-${hidden}`
return (
<Flex grow shrink p="$spacing4">
<ContextMenu itemId={itemId} menuOptions={menuOptions}>
<Flex
alignItems="center"
aspectRatio={1}
backgroundColor="$surface3"
borderRadius="$rounded12"
overflow="hidden"
width="100%"
onPress={onPress}
>
<NFTViewer
showSvgPreview
contractAddress={item.contractAddress}
imageDimensions={item.imageDimensions}
limitGIFSize={ESTIMATED_NFT_LIST_ITEM_SIZE}
placeholderContent={item.name || item.collectionName}
squareGridView={true}
tokenId={item.tokenId}
uri={item.imageUrl ?? ''}
/>
</Flex>
</ContextMenu>
</Flex>
)
}
const defaultEmptyStyle = {
minHeight: 100,
paddingVertical: '$spacing12',
width: '100%',
}
import { SpaceTokens } from 'ui/src'
export const SCREEN_ITEM_HORIZONTAL_PAD = '$spacing12' satisfies SpaceTokens
export enum GlobalErrorEvent {
ReduxStorageExceeded = 'ReduxStorageExceeded',
}
import EventEmitter from 'eventemitter3'
import { GlobalErrorEvent } from 'src/app/events/constants'
class GlobalEventEmitter extends EventEmitter<GlobalErrorEvent> {}
export const globalEventEmitter = new GlobalEventEmitter()
import { SharedEventName } from '@uniswap/analytics-events'
import { BaseSyntheticEvent, useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useDispatch } from 'react-redux'
import { EditLabelModal } from 'src/app/features/accounts/EditLabelModal'
import { removeAllDappConnectionsForAccount } from 'src/app/features/dapp/actions'
import { ContextMenu, Flex, MenuContentItem, Text, TouchableArea } from 'ui/src'
import { CopySheets, Edit, TrashFilled, TripleDots } from 'ui/src/components/icons'
import { iconSizes } from 'ui/src/theme'
import { usePortfolioTotalValue } from 'uniswap/src/features/dataApi/balances'
import { ElementName, ModalName } from 'uniswap/src/features/telemetry/constants'
import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send'
import { setClipboard } from 'uniswap/src/utils/clipboard'
import { NumberType } from 'utilities/src/format/types'
import { AddressDisplay } from 'wallet/src/components/accounts/AddressDisplay'
import { WarningModal } from 'wallet/src/components/modals/WarningModal/WarningModal'
import { usePortfolioValueModifiers } 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'
type AccountItemProps = {
address: Address
onAccountSelect?: () => void
}
export function AccountItem({ address, onAccountSelect }: AccountItemProps): JSX.Element {
const { t } = useTranslation()
const dispatch = useDispatch()
const valueModifiers = usePortfolioValueModifiers() ?? []
const { data, loading, error } = usePortfolioTotalValue({ address, valueModifiers })
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 && (
<WarningModal
caption={t('account.recoveryPhrase.remove.mnemonic.description', {
walletNames: [activeAccountDisplayName?.name ?? ''],
})}
closeText={t('common.button.cancel')}
confirmText={t('common.button.continue')}
icon={<TrashFilled color="$statusCritical" size="$icon.24" strokeWidth="$spacing1" />}
modalName={ModalName.RemoveWallet}
severity={WarningSeverity.High}
title={t('account.wallet.remove.title', { name: displayName?.name ?? '' })}
onClose={() => setShowRemoveWalletModal(false)}
onConfirm={onRemoveWallet}
/>
)}
{showEditLabelModal && <EditLabelModal address={address} onClose={() => setShowEditLabelModal(false)} />}
<TouchableArea
hoverable
backgroundColor="$surface1"
borderRadius="$rounded16"
cursor="pointer"
p="$spacing12"
onPress={onAccountSelect}
>
<Flex centered fill group row gap="$spacing8" justifyContent="space-between">
<AddressDisplay
address={address}
captionVariant="body3"
showViewOnlyBadge={false}
size={iconSizes.icon40}
variant="subheading2"
/>
<Flex>
<Text
$group-hover={{ opacity: 0 }}
color="$neutral2"
opacity={1}
position="absolute"
right={0}
top="50%"
transform="translateY(-50%)"
variant="body3"
>
{loading || error ? '' : formattedBalance}
</Text>
<ContextMenu closeOnClick itemId={address} menuOptions={menuOptions} onLeftClick>
<Flex
$group-hover={{ opacity: 1 }}
borderRadius="$roundedFull"
hoverStyle={{ backgroundColor: '$surface2Hovered' }}
opacity={0}
p="$spacing4"
>
<TripleDots color="$neutral2" size="$icon.16" />
</Flex>
</ContextMenu>
</Flex>
</Flex>
</TouchableArea>
</>
)
}
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(<AccountSwitcherScreen />, { preloadedState })
expect(tree).toMatchSnapshot()
cleanup()
})
})
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<string>('')
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 (
<BottomSheetModal name={ModalName.AccountEditLabel} onClose={onCancel}>
<Flex centered fill borderRadius="$rounded16" gap="$spacing24" mt="$spacing16">
<Flex centered gap="$spacing12" width="100%">
{onboardingAccountAddress && <AccountIcon address={onboardingAccountAddress} size={iconSizes.icon48} />}
<Flex borderColor="$surface3" borderRadius="$rounded16" borderWidth="$spacing1" width="100%">
<TextInput
autoFocus
borderRadius="$rounded16"
placeholder={placeholderText}
py="$spacing12"
textAlign="center"
value={inputText}
width="100%"
onChangeText={setInputText}
/>
</Flex>
{onboardingAccountAddress && (
<Text color="$neutral3" variant="body3">
{shortenAddress(onboardingAccountAddress)}
</Text>
)}
</Flex>
<Flex centered fill row gap="$spacing12" justifyContent="space-between" width="100%">
<Button color="$neutral1" flex={1} flexBasis={1} size="small" theme="secondary" onPress={onCancel}>
{t('common.button.cancel')}
</Button>
<Button
flex={1}
flexBasis={1}
hoverStyle={hoverAndPressButtonStyle}
pressStyle={hoverAndPressButtonStyle}
size="small"
style={{ color: uniconColor, backgroundColor: opacify(10, uniconColor) }}
onPress={onPressConfirm}
>
{t('common.button.create')}
</Button>
</Flex>
</Flex>
</BottomSheetModal>
)
}
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useDispatch } from 'react-redux'
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'
type EditLabelModalProps = {
address: Address
onClose: () => void
}
export function EditLabelModal({ address, onClose }: EditLabelModalProps): JSX.Element {
const { t } = useTranslation()
const dispatch = useDispatch()
const displayName = useDisplayName(address)
const defaultText = displayName?.type === DisplayNameType.Local ? displayName.name : ''
const [inputText, setInputText] = useState<string>(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 (
<BottomSheetModal name={ModalName.AccountEditLabel} onClose={onClose}>
<Flex centered fill borderRadius="$rounded16" gap="$spacing24" mt="$spacing16">
<Flex centered gap="$spacing12" width="100%">
<AccountIcon address={address} size={iconSizes.icon48} />
<Flex borderColor="$surface3" borderRadius="$rounded16" borderWidth="$spacing1" width="100%">
<TextInput
autoFocus
borderRadius="$rounded16"
placeholder={isfocused ? '' : t('account.wallet.edit.label.input.placeholder')}
textAlign="center"
value={inputText}
width="100%"
onBlur={() => setIsFocused(false)}
onChangeText={setInputText}
onFocus={() => setIsFocused(true)}
/>
</Flex>
<Text color="$neutral3" variant="body2">
{shortenAddress(address)}
</Text>
</Flex>
<Flex centered fill row gap="$spacing12" justifyContent="space-between" width="100%">
<Button color="$neutral1" flex={1} flexBasis={1} size="small" theme="secondary" onPress={onClose}>
{t('common.button.cancel')}
</Button>
<Button flex={1} flexBasis={1} size="small" theme="accentSecondary" onPress={onConfirm}>
{t('common.button.save')}
</Button>
</Flex>
</Flex>
</BottomSheetModal>
)
}
import { createContext, ReactNode, useContext, useEffect, useState } from 'react'
import { useDispatch } from 'react-redux'
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 { 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<DappContextState | undefined>(undefined)
export function DappContextProvider({ children }: { children: ReactNode }): JSX.Element {
const [dappUrl, setDappUrl] = useState('')
const [dappIconUrl, setDappIconUrl] = useState<string | undefined>(undefined)
const activeAddress = useActiveAccountAddress()
const connectedAccounts = useDappConnectedAccounts(dappUrl)
const lastChainId = useDappLastChainId(dappUrl)
const dispatch = useDispatch()
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 <DappContext.Provider value={value}>{children}</DappContext.Provider>
}
export function useDappContext(): DappContextState {
const context = useContext(DappContext)
if (context === undefined) {
throw new Error('useDappContext must be used within a DappContextProvider')
}
return context
}
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<void> {
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<void> {
dappStore.saveDappActiveAccount(dappUrl, account)
await updateConnectionFromExtension(dappUrl)
}
export async function removeDappConnection(dappUrl: string, account?: Account): Promise<void> {
dappStore.removeDappConnection(dappUrl, account)
await updateConnectionFromExtension(dappUrl)
}
async function updateConnectionFromExtension(dappUrl: string): Promise<void> {
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<void> {
dappStore.updateDappConnectedAddress(address)
const connectedDapps = dappStore.getConnectedDapps(address)
for (const dappUrl of connectedDapps) {
await updateConnectionFromExtension(dappUrl)
}
}
export async function removeAllDappConnectionsForAccount(account: Account): Promise<void> {
const connectedDapps = dappStore.getConnectedDapps(account.address)
for (const dappUrl of connectedDapps) {
await removeDappConnection(dappUrl, account)
}
}
export async function removeAllDappConnectionsFromExtension(): Promise<void> {
const dappUrls = dappStore.getDappUrls()
for (const dappUrl of dappUrls) {
const response: UpdateConnectionRequest = {
type: ExtensionToDappRequestType.UpdateConnections,
addresses: [],
}
await externalDappMessageChannel.sendMessageToTabUrl(dappUrl, response)
}
dappStore.removeAllDappConnections()
}
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,
})
})
})
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,
}
}
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]))
})
})
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<DappInfo>()
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 || []
}
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)
}
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<DappStoreEvent> {}
const dappStoreEventEmitter = new DappStoreEventEmitter()
// Init
let initPromise: Promise<void> | undefined
async function init(): Promise<void> {
if (!initPromise) {
initPromise = initInternal()
}
return initPromise
}
async function initInternal(): Promise<void> {
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<void> => {
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,
}
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)
})
})
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)
}
import { PropsWithChildren, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useDappLastChainId } from 'src/app/features/dapp/hooks'
import { useDappRequestQueueContext } from 'src/app/features/dappRequests/DappRequestQueueContext'
import { NetworksFooter } from 'src/app/features/dappRequests/requestContent/NetworksFooter'
import { DappRequestStoreItem } from 'src/app/features/dappRequests/slice'
import { Anchor, AnimatePresence, Button, Flex, Text, UniversalImage, UniversalImageResizeMode, styled } from 'ui/src'
import { iconSizes } from 'ui/src/theme'
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 { useOnChainNativeCurrencyBalance } from 'wallet/src/features/portfolio/api'
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 { hasSufficientFundsIncludingGas } from 'wallet/src/features/transactions/utils'
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
isUniswapX?: boolean
}
type DappRequestContentProps = DappRequestHeaderProps & DappRequestFooterProps
export 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 DappRequestContent({
chainId,
title,
headerIcon,
confirmText,
connectedAccountAddress,
maybeCloseOnConfirm,
onCancel,
onConfirm,
showAllNetworks,
showNetworkCost,
transactionGasFeeResult,
children,
isUniswapX,
}: PropsWithChildren<DappRequestContentProps>): JSX.Element {
const { forwards, currentIndex } = useDappRequestQueueContext()
return (
<>
<DappRequestHeader headerIcon={headerIcon} title={title} />
<AnimatePresence exitBeforeEnter custom={{ forwards }}>
<AnimatedPane key={currentIndex} animation="200ms">
{children}
</AnimatedPane>
</AnimatePresence>
<DappRequestFooter
chainId={chainId}
confirmText={confirmText}
connectedAccountAddress={connectedAccountAddress}
isUniswapX={isUniswapX}
maybeCloseOnConfirm={maybeCloseOnConfirm}
showAllNetworks={showAllNetworks}
showNetworkCost={showNetworkCost}
transactionGasFeeResult={transactionGasFeeResult}
onCancel={onCancel}
onConfirm={onConfirm}
/>
</>
)
}
function DappRequestHeader({ headerIcon, title }: DappRequestHeaderProps): JSX.Element {
const { dappIconUrl, dappUrl } = useDappRequestQueueContext()
const hostname = new URL(dappUrl).hostname.toUpperCase()
const fallbackIcon = <DappIconPlaceholder iconSize={iconSizes.icon40} name={hostname} />
return (
<Flex mb="$spacing4" ml="$spacing8" mt="$spacing8">
<Flex row>
<Flex grow>
{headerIcon || (
<UniversalImage
fallback={fallbackIcon}
size={{
width: iconSizes.icon40,
height: iconSizes.icon40,
resizeMode: UniversalImageResizeMode.Contain,
}}
uri={dappIconUrl}
/>
)}
</Flex>
</Flex>
<Text mt="$spacing8" variant="subheading1">
{title}
</Text>
<Anchor href={dappUrl} rel="noopener noreferrer" target="_blank" textDecorationLine="none">
<Text color="$accent1" mt="$spacing4" textAlign="left" variant="body4">
{formatDappURL(dappUrl)}
</Text>
</Anchor>
</Flex>
)
}
const WINDOW_CLOSE_DELAY = 10
export function DappRequestFooter({
chainId,
connectedAccountAddress,
confirmText,
maybeCloseOnConfirm,
onCancel,
onConfirm,
showAllNetworks,
showNetworkCost,
transactionGasFeeResult,
isUniswapX,
}: 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 { balance: nativeBalance } = useOnChainNativeCurrencyBalance(currentChainId, currentAccount.address)
const hasSufficientGas = hasSufficientFundsIncludingGas({
gasFee: transactionGasFeeResult?.value,
nativeCurrencyBalance: nativeBalance,
})
const shouldCloseSidebar = request.isSidebarClosed && totalRequestCount <= 1
const isConfirmDisabled = transactionGasFeeResult ? !gasFeeUSD || !hasSufficientGas : false
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 (
<>
<Flex gap="$spacing8" mt="$spacing8">
{!hasSufficientGas && (
<Flex pb="$spacing8">
<Text color="$DEP_accentWarning" variant="body3">
{t('swap.warning.insufficientGas.title', {
currencySymbol: nativeBalance?.currency?.symbol,
})}
</Text>
</Flex>
)}
{showNetworkCost && (
<NetworkFeeFooter
chainId={currentChainId}
gasFeeUSD={transactionGasFeeResult ? gasFeeUSD : '0'}
isUniswapX={isUniswapX}
showNetworkLogo={!!transactionGasFeeResult}
/>
)}
{showAllNetworks && <NetworksFooter />}
<AddressFooter
activeAccountAddress={activeAccount.address}
connectedAccountAddress={connectedAccountAddress || currentAccount.address}
px="$spacing8"
/>
<Flex row gap="$spacing12" pt="$spacing8">
<Button flex={1} flexBasis={1} size="small" theme="secondary" onPress={handleOnCancel}>
{t('common.button.cancel')}
</Button>
<Button
disabled={isConfirmDisabled}
flex={1}
flexBasis={1}
size="small"
theme="primary"
onPress={handleOnConfirm}
>
{confirmText}
</Button>
</Flex>
</Flex>
</>
)
}
import { providerErrors, serializeError } from '@metamask/rpc-errors'
import { PropsWithChildren, createContext, useContext, useEffect, useRef, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
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 { ExtensionState } from 'src/store/extensionReducer'
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<void>
onCancel: (request: DappRequestStoreItem) => Promise<void>
}
const DappRequestQueueContext = createContext<DappRequestQueueContextValue | undefined>(undefined)
export function DappRequestQueueProvider({ children }: PropsWithChildren): JSX.Element {
const dispatch = useDispatch()
const [currentIndex, setCurrentIndex] = useState(0)
// Show the top most pending request
const pendingRequests = useSelector((state: ExtensionState) => 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<void> => {
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<void> => {
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 <DappRequestQueueContext.Provider value={value}>{children}</DappRequestQueueContext.Provider>
}
export function useDappRequestQueueContext(): DappRequestQueueContextValue {
const context = useContext(DappRequestQueueContext)
if (context === undefined) {
throw new Error('useDappRequestQueueContext must be used within a DappRequestQueueProvider')
}
return context
}
import { memo } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { useDispatch } from 'react-redux'
import { AnimatedPane, DappRequestContent } from 'src/app/features/dappRequests/DappRequestContent'
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 { 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 { isDappRequestStoreItemForEthSendTxn } from 'src/app/features/dappRequests/slice'
import {
isGetAccountRequest,
isRequestAccountRequest,
isRequestPermissionsRequest,
isSignMessageRequest,
isSignTypedDataRequest,
} from 'src/app/features/dappRequests/types/DappRequestTypes'
import { AnimatePresence, Flex, Text, TouchableArea, 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'
const REJECT_MESSAGE_HEIGHT = 48
export function DappRequestWrapper(): JSX.Element {
const { t } = useTranslation()
const colors = useSporeColors()
const dispatch = useDispatch()
const { totalRequestCount, onPressPrevious, onPressNext, currentIndex, increasing } = useDappRequestQueueContext()
const disabledPrevious = currentIndex <= 0
const disabledNext = currentIndex >= totalRequestCount - 1
const onRejectAll = async (): Promise<void> => {
dispatch(rejectAllRequests())
}
return (
<BottomSheetModal alignment="top" backgroundColor="$transparent" name={ModalName.DappRequest} padding="$none">
<Flex>
<AnimatePresence>
{totalRequestCount > 1 && (
<Flex
row
alignItems="center"
animateEnterExit="fadeInDownOutUp"
animation="200ms"
backgroundColor="$surface1"
borderRadius="$rounded16"
gap="$spacing4"
justifyContent="center"
minHeight={REJECT_MESSAGE_HEIGHT}
p="$spacing12"
>
<ReceiptText color="$neutral2" size={iconSizes.icon20} />
<Flex grow>
<Text color="$neutral2" variant="body4">
<Trans
components={{
highlight: (
<Text
color="$neutral2"
opacity={1}
variant="body4"
// `variant` prop must be first
// eslint-disable-next-line react/jsx-sort-props
fontWeight="500"
/>
),
}}
i18nKey="dapp.request.reject.info"
values={{ totalRequestCount }}
/>
</Text>
</Flex>
<TouchableArea onPress={onRejectAll}>
<Text color="$statusCritical" fontWeight="500" variant="body4">
{t('dapp.request.reject.action')}
</Text>
</TouchableArea>
</Flex>
)}
</AnimatePresence>
<Flex
animation="200ms"
backgroundColor="$surface1"
borderRadius="$rounded24"
gap="$spacing12"
p="$spacing12"
position="absolute"
width="100%"
y={totalRequestCount > 1 ? REJECT_MESSAGE_HEIGHT + 12 : 0}
>
{totalRequestCount > 1 && (
<Flex
row
alignSelf="flex-start"
backgroundColor="$surface2"
borderRadius="$rounded8"
justifyContent="center"
p="$spacing4"
position="absolute"
right={12}
zIndex={zIndices.fixed}
>
<TouchableArea
borderRadius="$rounded4"
disabled={disabledPrevious}
disabledStyle={{
cursor: 'default',
}}
hoverStyle={{
backgroundColor: colors.surface2Hovered.val,
}}
onPress={onPressPrevious}
>
<RotatableChevron
color={disabledPrevious ? '$neutral3' : '$neutral2'}
direction="left"
height={iconSizes.icon16}
width={iconSizes.icon16}
/>
</TouchableArea>
<Text color="$neutral2" variant="buttonLabel4">
{currentIndex + 1}
</Text>
<Text color="$neutral2" mx="$spacing4" variant="buttonLabel4">
/
</Text>
<AnimatePresence exitBeforeEnter custom={{ increasing }} initial={false}>
<AnimatedPane key={totalRequestCount} animation="200ms">
<Text color="$neutral2" variant="buttonLabel4">
{totalRequestCount}
</Text>
</AnimatedPane>
</AnimatePresence>
<TouchableArea
borderRadius="$rounded4"
disabled={disabledNext}
disabledStyle={{ cursor: 'default' }}
hoverStyle={{
backgroundColor: colors.surface2Hovered.val,
}}
onPress={onPressNext}
>
<RotatableChevron
color={disabledNext ? '$neutral3' : '$neutral2'}
direction="right"
height={iconSizes.icon16}
width={iconSizes.icon16}
/>
</TouchableArea>
</Flex>
)}
<DappRequest />
</Flex>
</Flex>
</BottomSheetModal>
)
}
const DappRequest = memo(function _DappRequest(): JSX.Element {
const { t } = useTranslation()
const { request } = useDappRequestQueueContext()
if (request) {
if (isSignMessageRequest(request.dappRequest)) {
return <PersonalSignRequestContent dappRequest={request.dappRequest} />
}
if (isSignTypedDataRequest(request.dappRequest)) {
return <SignTypedDataRequestContent dappRequest={request.dappRequest} />
}
if (isDappRequestStoreItemForEthSendTxn(request)) {
return <EthSendRequestContent request={request} />
}
if (
isGetAccountRequest(request.dappRequest) ||
isRequestAccountRequest(request.dappRequest) ||
isRequestPermissionsRequest(request.dappRequest)
) {
return <ConnectionRequestContent />
}
}
return <DappRequestContent confirmText={t('common.button.confirm')} title={t('dapp.request.base.title')} />
})
/* 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, select } 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'
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* select(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,
})
}
}
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)
}
/* 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)
}
}
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 (
<DappRequestContent
showAllNetworks
confirmText={t('common.button.connect')}
title={t('dapp.request.connect.title')}
>
<Flex
backgroundColor="$surface2"
borderColor="$surface3"
borderRadius="$rounded12"
borderWidth="$spacing1"
p="$spacing12"
>
<Text color="$neutral2" variant="body4">
{t('dapp.request.connect.helptext')}
</Text>
</Flex>
</DappRequestContent>
)
}
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<void>
onConfirm: () => Promise<void>
}
export function LPRequestContent({
dappRequest,
transactionGasFeeResult,
onCancel,
onConfirm,
}: LPRequestContentProps): JSX.Element {
const { t } = useTranslation()
return (
<DappRequestContent
showNetworkCost
confirmText={t('common.button.sign')}
title={t('dapp.request.base.title')}
transactionGasFeeResult={transactionGasFeeResult}
onCancel={onCancel}
onConfirm={onConfirm}
>
<Flex
alignItems="flex-start"
backgroundColor="$surface2"
borderColor="$surface3"
borderRadius="$rounded16"
borderWidth={1}
flexDirection="row"
justifyContent="space-between"
p="$spacing16"
>
{dappRequest.parsedCalldata.commands.map((command) => (
<Text color="$neutral2" variant="body4">
{command.commandName}
</Text>
))}
</Flex>
</DappRequestContent>
)
}
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment