init: extract wallet from lux/exchange monorepo

Copies apps/extension, apps/mobile and 10 pkgs (wallet, ui, config,
utilities, sessions, gating, notifications, analytics, eslint-config,
prices) into a standalone pnpm workspace.  Resolves merge conflict
markers in extension and mobile package.json files, strips build
artifacts (.wxt, .output, Pods).
This commit is contained in:
Hanzo AI
2026-04-11 01:59:19 -07:00
commit 78b18797d5
3958 changed files with 331593 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
node_modules
.next
out
dist
build
*.tsbuildinfo
.turbo
.env*.local
.expo
.cache
.wxt
.output
Pods/
coverage
test-results
*.log
.DS_Store
+36
View File
@@ -0,0 +1,36 @@
ignores: [
# Dependencies that depcheck thinks are unused but are actually used
'concurrently',
'react-native-web',
'jest-environment-jsdom',
<<<<<<< HEAD
'@wxt-dev/module-react',
'serve',
'typescript',
=======
'webpack-cli',
'@wxt-dev/module-react',
'serve',
'typescript',
'eslint',
>>>>>>> upstream/main
'@typescript/native-preview',
# Dependencies that depcheck thinks are missing but are actually present or never used
## Internal packages / workspaces
'src',
'e2e',
'tsconfig',
'config',
<<<<<<< HEAD
=======
# Webpack plugins
'@svgr/webpack',
'tamagui-loader',
'esbuild-loader',
'style-loader',
'css-loader',
'swc-loader',
>>>>>>> upstream/main
## Testing
'@testing-library/dom',
]
+1
View File
@@ -0,0 +1 @@
jest-setup.js
+71
View File
@@ -0,0 +1,71 @@
const restrictedGlobals = require('confusing-browser-globals')
const rulesDirPlugin = require('eslint-plugin-rulesdir')
rulesDirPlugin.RULES_DIR = '../../pkgs/lx/eslint_rules'
module.exports = {
root: true,
extends: ['@luxfi/eslint-config/extension'],
plugins: ['rulesdir'],
ignorePatterns: [
'node_modules',
'dist',
'.turbo',
'build',
'.eslintrc.js',
'.nx',
'wxt.config.ts',
],
parserOptions: {
project: 'tsconfig.eslint.json',
tsconfigRootDir: __dirname,
ecmaFeatures: {
jsx: true,
},
ecmaVersion: 2018,
sourceType: 'module',
},
rules: {
'rulesdir/i18n': 'error',
},
overrides: [
{
files: ['src/assets/index.ts', 'src/contentScript/index.tsx'],
rules: {
'check-file/no-index': 'off',
},
},
{
files: ['*.ts', '*.tsx'],
rules: {
'no-relative-import-paths/no-relative-import-paths': [
'error',
{
allowSameFolder: false,
},
],
},
},
{
files: ['**/contentScript/**'],
rules: {
'no-restricted-syntax': [
'error',
{
selector: 'CallExpression[callee.object.name="logger"][callee.property.name!=/^(debug)$/]',
message:
'Only `logger.debug` is allowed in the content scripts. Please handle errors logs explicitly using `ErrorLog` message passing via `logContentScriptError`.',
},
],
},
},
{
// We override this rule from the base config to allow access to `chrome`
// in all Extension files except those in the `contentScript` folder.
files: ['*.ts', '*.tsx'],
excludedFiles: ['**/contentScript/**'],
rules: {
'no-restricted-globals': ['error'].concat(restrictedGlobals),
},
},
],
}
+40
View File
@@ -0,0 +1,40 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.tamagui
.output
.wxt
# Personal web-ext configuration
web-ext.config.ts
tsconfig.tsbuildinfo
# E2E test artifacts
e2e/test-results/
coverage/
dev/
+91
View File
@@ -0,0 +1,91 @@
{
"extends": ["../../.oxlintrc.json"],
"ignorePatterns": [".maestro/**", "dev/**", "webpack*.js", "webpack-plugins/**"],
"rules": {
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "ethers",
"message": "Please import from '@ethersproject/module' directly to support tree-shaking."
}
]
}
],
"no-restricted-syntax": [
"error",
{
"selector": "CallExpression[callee.property.name='sendMessage'][callee.object.property.name='tabs'][callee.object.object.name='chrome']",
"message": "Use a message channel from apps/extension/src/background/messagePassing/messageChannels.ts instead of chrome.tabs.sendMessage."
},
{
"selector": "CallExpression[callee.property.name='sendMessage'][callee.object.property.name='runtime'][callee.object.object.name='chrome']",
"message": "Use a message channel from apps/extension/src/background/messagePassing/messageChannels.ts instead of chrome.runtime.sendMessage."
},
{
"selector": "CallExpression[callee.property.name='addListener'][callee.object.property.name='onMessage'][callee.object.object.property.name='runtime'][callee.object.object.object.name='chrome']",
"message": "Use a message channel instead of chrome.runtime.onMessage.addListener."
},
{
"selector": "CallExpression[callee.property.name='removeListener'][callee.object.property.name='onMessage'][callee.object.object.property.name='runtime'][callee.object.object.object.name='chrome']",
"message": "Use a message channel instead of chrome.runtime.onMessage.removeListener."
},
{
"selector": "CallExpression[callee.object.name='z'][callee.property.name='any']",
"message": "Avoid using z.any() in favor of more precise custom types."
}
],
"complexity": ["error", 20],
"max-depth": ["error", 4],
"max-nested-callbacks": ["error", 3],
"typescript/explicit-function-return-type": "off"
},
"overrides": [
{
"files": ["**/*.ts", "**/*.tsx"],
"rules": {
"universe-custom/no-relative-import-paths": [
"error",
{
"allowSameFolder": false
}
]
}
},
{
"files": ["**/*"],
"rules": {
"no-restricted-globals": "off"
}
},
{
"files": ["**/contentScript/**"],
"rules": {
"no-restricted-globals": [
"error",
{
"name": "chrome",
"message": "Direct `chrome` access is restricted. Use `getChrome()` or `getChromeWithThrow()` instead."
}
]
}
},
{
"files": ["webpack*.js", "webpack-plugins/**"],
"rules": {
"security/detect-non-literal-regexp": "off",
"no-console": "off"
}
},
{
"files": ["**/extensionMigrations.ts", "**/extensionMigrationsTests.ts"],
"rules": {
"typescript/prefer-enum-initializers": "off",
"typescript/no-non-null-assertion": "off",
"typescript/no-empty-interface": "off",
"typescript/no-explicit-any": "off"
}
}
]
}
+95
View File
@@ -0,0 +1,95 @@
# Rabby Features Migration Tracker
This document tracks the migration of features from the Rabby-based xwallet to the Lux Exchange extension.
## Overview
The Lux Exchange extension is built on a modern stack (WXT, React 19, Tamagui) and integrates deeply with the Lux/Lux DEX ecosystem. We're augmenting it with battle-tested features from the Rabby wallet fork (xwallet).
## Feature Status
### Critical (Must Have)
| Feature | Status | Source | Target | Notes |
|---------|--------|--------|--------|-------|
| Ledger Support | 🟡 IN PROGRESS | `xwallet/src/background/service/keyring/eth-ledger-keyring.ts` | `src/features/hardware-wallets/ledger/` | WebHID, HD paths - Bridge implemented |
| Trezor Support | 🔴 TODO | `xwallet/src/background/service/keyring/eth-trezor-keyring.ts` | `src/features/hardware-wallets/trezor/` | WebExtension transport |
| Keystone Support | 🔴 TODO | `xwallet/src/background/service/keyring/eth-keystone-keyring.ts` | `src/features/hardware-wallets/keystone/` | QR code signing |
| WalletConnect v2 | 🔴 TODO | `xwallet` keyring | `src/features/walletconnect/` | Remote signing |
| Custom Networks | 🟢 DONE | `wallet_addEthereumChain` | `src/features/custom-networks/` | Service implemented |
| Custom Tokens | 🔴 TODO | `wallet_watchAsset` | `src/features/custom-tokens/` | Token import |
### High Priority
| Feature | Status | Source | Target | Notes |
|---------|--------|--------|--------|-------|
| Security Engine | 🟢 DONE | `xwallet/src/background/service/securityEngine.ts` | `src/background/services/security/` | Core engine implemented |
| Approval Manager | 🔴 TODO | `xwallet/src/ui/views/ApprovalManagePage/` | `src/features/approval-management/` | Batch revoke |
| Address Book | 🟢 DONE | `xwallet/src/background/service/contactBook.ts` | `src/features/address-book/` | Service implemented |
| Tx Simulation | 🔴 TODO | Via security engine | `src/features/tx-simulation/` | Preview |
| Safe/Gnosis | 🔴 TODO | `xwallet/src/background/service/keyring/eth-gnosis-keyring.ts` | `src/features/multisig/` | Multisig |
### Medium Priority
| Feature | Status | Source | Target | Notes |
|---------|--------|--------|--------|-------|
| Custom Testnets | 🔴 TODO | `xwallet/src/background/service/customTestnet.ts` | `src/features/custom-networks/testnets/` | Custom EVM |
| Bridge Integration | 🔴 TODO | `xwallet/src/background/service/bridge.ts` | `src/features/bridge/` | Multi-aggregator |
| Advanced Gas UI | 🔴 TODO | preference service | `src/features/gas/` | Custom presets |
| DApp Favorites | 🔴 TODO | `xwallet/src/background/service/permission.ts` | `src/features/dapp/` | Pin/favorite |
## Architecture Decisions
### Hardware Wallet Integration
The exchange extension uses WXT (Vite-based) which requires different handling than webpack:
- Use offscreen documents for WebHID/USB communication
- Maintain MV3 compatibility with service worker limitations
- Share keyring interfaces with existing wallet package
### State Management
- Extend existing Redux store with new slices for hardware wallets
- Add migrations for schema changes
- Use Redux Saga for async hardware operations
### UI Components
- Build on existing Tamagui component library
- Follow feature-based folder structure
- Integrate with existing navigation system
## Dependencies to Add
```json
{
"@ledgerhq/hw-app-eth": "^6.x",
"@ledgerhq/hw-transport-webusb": "^6.x",
"@trezor/connect-webextension": "^9.x",
"@keystonehq/keystone-sdk": "^0.x",
"@walletconnect/web3wallet": "^1.x",
"@safe-global/safe-core-sdk": "^3.x"
}
```
## Migration Notes
### From xwallet
The xwallet codebase uses:
- Webpack build system
- Class-based services with singleton pattern
- Chrome storage API directly
- Broadcast messaging pattern
We adapt to:
- WXT/Vite build system
- Functional approach with hooks where possible
- Redux persist with webextension storage adapter
- Redux action dispatch pattern
### Testing
- Port relevant tests from xwallet
- Add E2E tests for hardware wallet flows
- Mock hardware wallet devices in tests
+99
View File
@@ -0,0 +1,99 @@
# Uniswap Extension
## Developer Quickstart
### Environment variables
Before running the extension, you need to get the environment variables from 1password in order to get full functionality. Run the command `bun extension env:local:download` to copy them to your root folder.
---
#### Option 1: WXT (recommended)
```bash
bun extension dev
```
WXT automatically opens a browser window with the extension loaded.
##### Configuring WXT browser behavior
To customize the browser WXT opens, create a file `web-ext.config.ts` in this directory:
```ts
// web-ext.config.ts
import { defineWebExtConfig } from 'wxt';
export default defineWebExtConfig({
// Option 1: Connect to already running Chrome (requires Chrome to be started with --remote-debugging-port=9222)
// chromiumPort: 9222,
// Option 2: Use your existing Chrome profile (but Chrome must be closed first)
// chromiumArgs: [
// '--user-data-dir=/Users/<username>/Library/Application Support/Google/Chrome',
// '--profile-directory=Default'
// ],
// Option 3: Create a persistent profile that matches your existing setup (recommended)
chromiumArgs: [
'--user-data-dir=./.wxt/chrome-data',
// Sync with your Google account to get bookmarks, extensions, etc.
// '--enable-sync',
],
});
```
##### Running WXT with absolute paths (for Scantastic testing)
```bash
# Mac
bun extension start:absolute
# Windows
bun extension start:absolute:windows
```
---
#### Option 2: Webpack (legacy)
```bash
bun extension start:webpack
```
Then manually load 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 Webpack with absolute paths (for Scantastic testing)
Our Scantastic API requires a consistent origin header, so the build must be loaded from an absolute path. Chrome generates a consistent extension ID based on the path it was loaded from.
```bash
# Mac
bun extension start:webpack:absolute
# Windows
bun extension start:webpack:absolute:windows
```
Then manually load 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 with an absolute path:
- Mac: `/Users/Shared/stretch`
- Windows: `C:/ProgramData/stretch`
5. Your extension URL should be:
- Mac: `chrome-extension://ceofpnbcmdjbibjjdniemjemmgaibeih`
- Windows: `chrome-extension://ffogefanhjekjafbpofianlhkonejcoe`
The backend allows these origins, and the ID is consistently generated based on the absolute path.
## 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.
+16
View File
@@ -0,0 +1,16 @@
// Simple mock for expo-blur's BlurView
<<<<<<< HEAD
// We don't actually need to use `expo-blur` in the extension, as we just use CSS; so, we can mock it out.
=======
// This is needed to avoid the Storybook Web compilation error related to `expo-blur`, something like:
// Module parse failed: Unexpected token (29:12)
// node_modules/expo-blur/build/BlurView.web.js 29:12
// You may need an appropriate loader to handle this file type, currently no loaders are configured to process this file. See https://webpack.js.org/concepts#loaders
// We don't actually need to use `expo-blur` in the Web App, as we just use CSS; so, we can mock it out.
// oxlint-disable-next-line react/forbid-elements -- biome-parity: oxlint is stricter here
>>>>>>> upstream/main
export const BlurView = (props) => <div {...props} />
export default BlurView
@@ -0,0 +1,25 @@
/* oxlint-disable universe-custom/no-relative-import-paths */
import path from 'path'
import { getTsconfigAliases } from './getTsconfigAliases'
describe('getTsconfigAliases', () => {
it('should throw error when tsconfig file does not exist', () => {
const nonExistentPath = '/path/that/does/not/exist/tsconfig.json'
expect(() => getTsconfigAliases(nonExistentPath)).toThrow(`tsconfig file not found at: ${nonExistentPath}`)
})
it('should successfully parse the real tsconfig.base.json', () => {
const result = getTsconfigAliases()
// Verify we got aliases for some known packages
expect(result).toHaveProperty('uniswap')
expect(result).toHaveProperty('@universe/api')
// Verify paths are absolute and point to the packages directory
expect(result['uniswap']).toContain('packages/uniswap')
expect(result['@universe/api']).toContain('packages/api')
expect(path.isAbsolute(result['uniswap']!)).toBe(true)
expect(path.isAbsolute(result['@universe/api']!)).toBe(true)
})
})
@@ -0,0 +1,52 @@
/* oxlint-disable import/no-unused-modules */
import fs from 'fs'
import path from 'path'
/**
* Dynamically generates Vite aliases from TypeScript path mappings in tsconfig.base.json.
* This ensures Vite understands monorepo package paths without manual configuration.
*
* @param tsconfigPath - Path to the tsconfig file. Defaults to ../../tsconfig.base.json relative to this file.
* @returns Record of alias names to resolved absolute paths
*/
export function getTsconfigAliases(
tsconfigPath: string = path.resolve(__dirname, '../../../tsconfig.base.json'),
): Record<string, string> {
const aliases: Record<string, string> = {}
// Validate tsconfig file exists
if (!fs.existsSync(tsconfigPath)) {
throw new Error(`tsconfig file not found at: ${tsconfigPath}`)
}
// Read and parse tsconfig file
let tsconfig: { compilerOptions?: { paths?: Record<string, string[]> } }
try {
const tsconfigContent = fs.readFileSync(tsconfigPath, 'utf-8')
tsconfig = JSON.parse(tsconfigContent)
} catch (error) {
throw new Error(`Failed to read or parse tsconfig at ${tsconfigPath}`, { cause: error })
}
// Extract paths from compilerOptions
const paths: Record<string, string[]> | undefined = tsconfig.compilerOptions?.paths
if (!paths || typeof paths !== 'object') {
return aliases
}
const tsconfigDir = path.dirname(tsconfigPath)
for (const [alias, pathMapping] of Object.entries(paths)) {
if (!Array.isArray(pathMapping) || !pathMapping[0]) {
continue
}
const targetPath = pathMapping[0]
// Strip wildcard /* from both alias and target if present
const aliasBase = alias.replace(/\/\*$/, '')
const targetPathBase = targetPath.replace(/\/\*$/, '')
// Resolve to absolute path relative to tsconfig directory
aliases[aliasBase] = path.resolve(tsconfigDir, targetPathBase)
}
return aliases
}
+84
View File
@@ -0,0 +1,84 @@
# Extension E2E Tests
End-to-end tests for the Uniswap Chrome Extension using Playwright.
## Setup
1. Install dependencies (from repo root):
```bash
bun install
```
2. Install Playwright browsers (from extension directory):
```bash
cd apps/extension
bun playwright install chromium
```
## Running Tests
### Build and run all tests:
```bash
bun run e2e
```
### Build and run smoke tests only:
```bash
bun run e2e:smoke
```
### Run tests without rebuilding:
```bash
bun run playwright:test
```
### Run tests in UI mode (for debugging):
```bash
bun playwright test --ui --config=e2e/config/playwright.config.ts
```
### Run tests in headless environment (CI/SSH):
Chrome extensions require a display server. If you're running in a headless environment, use xvfb:
```bash
# Install xvfb if needed
sudo apt-get install xvfb
# Run tests with xvfb
xvfb-run -a bun run e2e:smoke
```
## Test Structure
- `config/` - Playwright configuration
- `fixtures/` - Test fixtures for extension loading
- `tests/smoke/` - Smoke tests for critical functionality
- `basic-setup.test.ts` - Verifies extension loads correctly
- `onboarding-flow.test.ts` - Tests fresh install onboarding
- `sidebar-loads.test.ts` - Tests sidebar functionality (auto-onboards)
- `wallet-connection.test.ts` - Tests dApp connection flow (auto-onboards)
- `utils/` - Helper utilities including programmatic onboarding
## Test Fixtures
### freshExtensionTest
- Loads extension with no user data
- Triggers onboarding flow
- Use for testing fresh installation scenarios
### onboardedExtensionTest
- Loads extension with fresh user data
- Automatically completes onboarding with test wallet
- Uses test seed phrase: `test test test test test test test test test test test junk`
- Use for testing wallet functionality
## Programmatic Onboarding
Tests that require an onboarded extension will automatically complete the onboarding process using a test seed phrase. This approach:
- Avoids committing sensitive user data
- Ensures consistent test environment
- Works identically in CI and local development
- No manual setup required
## CI Integration
Tests run automatically on PRs that affect the extension or its dependencies using GitHub Actions.
@@ -0,0 +1,29 @@
import { defineConfig } from '@playwright/test'
import ms from 'ms'
const IS_CI = process.env.CI === 'true'
// oxlint-disable-next-line import/no-unused-modules
export default defineConfig({
testDir: '../tests',
testMatch: '**/*.test.ts',
workers: 1,
fullyParallel: false, // Extensions need sequential loading
maxFailures: IS_CI ? 10 : undefined,
retries: IS_CI ? 3 : 0,
reporter: 'list',
timeout: ms('60s'),
expect: {
timeout: ms('10s'),
},
use: {
screenshot: 'off',
video: 'retain-on-failure',
trace: 'retain-on-failure',
headless: false, // Chrome extensions require headed mode
launchOptions: {
args: ['--disable-blink-features=AutomationControlled'],
},
},
outputDir: '../test-results',
})
@@ -0,0 +1,44 @@
import os from 'os'
import path from 'path'
import { type BrowserContext, chromium } from '@playwright/test'
interface CreateExtensionContextOptions {
/** Prefix for the user data directory (for test isolation) */
userDataDirPrefix?: string
}
/**
* Creates a persistent browser context with the extension loaded.
* Returns both the context and cleanup function.
*/
export async function createExtensionContext(options: CreateExtensionContextOptions = {}): Promise<BrowserContext> {
const { userDataDirPrefix = 'playwright-extension' } = options
const extensionPath = path.join(__dirname, '../../build')
// Generate a unique user data directory for each test to ensure isolation
const userDataDir = path.join(
os.tmpdir(),
`${userDataDirPrefix}-${Date.now()}-${Math.random().toString(36).substring(7)}`,
)
// CI environments need different args for headless-like behavior
const isCI = process.env.CI === 'true'
const context = await chromium.launchPersistentContext(userDataDir, {
headless: false, // Chrome extensions require headed mode
args: [
`--disable-extensions-except=${extensionPath}`,
`--load-extension=${extensionPath}`,
'--no-default-browser-check',
'--disable-default-apps',
'--no-sandbox', // Required for CI
'--disable-setuid-sandbox',
'--disable-dev-shm-usage', // Overcome limited resource problems in CI
...(isCI ? ['--disable-gpu', '--disable-software-rasterizer'] : []),
],
viewport: { width: 1280, height: 720 },
})
return context
}
@@ -0,0 +1,14 @@
// oxlint-disable-next-line no-empty-pattern -- fixture file
context: async ({}, use) => {
const context = await createExtensionContext()
await use(context)
await context.close()
},
extensionId: async ({ context }, use) => {
const { extensionId } = await waitForExtensionLoad(context, { timeout: 10000 })
await use(extensionId)
},
})
// Re-export the programmatic onboarded extension test fixture
export { onboardedExtensionTest } from './onboarded-extension.fixture'
@@ -0,0 +1,50 @@
/* oxlint-disable no-console -- fixture file */
/* oxlint-disable react-hooks/rules-of-hooks -- Playwright fixtures use `use()` which is not a React hook */
import { type BrowserContext, test as base } from '@playwright/test'
import { createExtensionContext } from 'e2e/fixtures/extension-context'
import { completeOnboarding } from 'e2e/utils/onboarding-helpers'
import { waitForExtensionLoad } from 'e2e/utils/wait-for-extension'
import { ONE_SECOND_MS } from 'utilities/src/time/time'
interface OnboardedExtensionFixtures {
context: BrowserContext
extensionId: string
}
// Extension test fixture that programmatically completes onboarding
export const onboardedExtensionTest = base.extend<OnboardedExtensionFixtures>({
// oxlint-disable-next-line no-empty-pattern -- fixture file
context: async ({}, use) => {
const context = await createExtensionContext({
userDataDirPrefix: 'playwright-extension-onboarded',
})
try {
// Wait for extension to load and onboarding to appear
const { onboardingPage } = await waitForExtensionLoad(context, {
timeout: ONE_SECOND_MS * 10,
waitForOnboarding: true,
})
// Complete onboarding programmatically
if (onboardingPage) {
await completeOnboarding(context, onboardingPage)
} else {
// Try to complete onboarding anyway - it might open later
await completeOnboarding(context)
}
} catch (error) {
console.error('Failed to complete onboarding:', error)
await context.close()
throw error
}
await use(context)
await context.close()
},
extensionId: async ({ context }, use) => {
const { extensionId } = await waitForExtensionLoad(context, { timeout: ONE_SECOND_MS * 10 })
await use(extensionId)
},
})
@@ -0,0 +1,64 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Sandbox Child Frame</title>
<style>
body { font-family: monospace; padding: 8px; margin: 0; background: #1a1a2e; color: #e0e0e0; font-size: 11px; }
.status { padding: 4px 8px; border-radius: 4px; margin: 2px 0; }
.pass { background: #0f5132; color: #75b798; }
.fail { background: #842029; color: #ea868f; }
.neutral { background: #333; color: #aaa; }
</style>
</head>
<body>
<div id="info">Detecting providers...</div>
<script>
const origin = window.origin;
const hasEthereum = typeof window.ethereum !== 'undefined' && window.ethereum !== null;
// Listen for Uniswap's EIP-6963 announceProvider event
let hasUniswapProvider = false;
const UNISWAP_RDNS = 'org.uniswap.app';
window.addEventListener('eip6963:announceProvider', (event) => {
if (event.detail && event.detail.info && event.detail.info.rdns === UNISWAP_RDNS) {
hasUniswapProvider = true;
updateDisplay();
}
});
// Request providers via EIP-6963
window.dispatchEvent(new Event('eip6963:requestProvider'));
// Give providers time to respond, then report
setTimeout(() => {
updateDisplay();
report();
}, 500);
function updateDisplay() {
const otherWallet = hasEthereum && !hasUniswapProvider;
document.getElementById('info').innerHTML = `
<div class="status">origin: <strong>${origin}</strong></div>
<div class="status ${hasUniswapProvider ? 'pass' : 'fail'}">
Uniswap provider (EIP-6963): <strong>${hasUniswapProvider ? 'PRESENT' : 'ABSENT'}</strong>
</div>
${otherWallet ? '<div class="status neutral">window.ethereum present from another wallet (ignored)</div>' : ''}
`;
}
function report() {
try {
window.parent.postMessage({
type: 'sandbox-test-report',
origin: origin,
hasUniswapProvider: hasUniswapProvider,
}, '*');
} catch (e) {
// postMessage may fail in some sandbox configurations
}
}
</script>
</body>
</html>
+135
View File
@@ -0,0 +1,135 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Extension Sandbox Origin Validation Test</title>
<style>
* { box-sizing: border-box; }
body { font-family: system-ui, -apple-system, sans-serif; background: #0f0f23; color: #e0e0e0; padding: 24px; margin: 0; }
h1 { color: #ff79c6; margin-bottom: 4px; }
.subtitle { color: #888; margin-bottom: 24px; }
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 24px; }
.card { background: #1a1a2e; border: 1px solid #333; border-radius: 8px; padding: 16px; }
.card h3 { margin: 0 0 8px; font-size: 14px; color: #bd93f9; }
.card .detail { font-size: 12px; color: #888; margin-bottom: 12px; }
.card iframe { width: 100%; height: 100px; border: 1px solid #444; border-radius: 4px; }
.result { margin-top: 12px; padding: 8px; border-radius: 4px; font-size: 13px; font-weight: 600; }
.result.pass { background: #0f5132; color: #75b798; border: 1px solid #0f5132; }
.result.fail { background: #842029; color: #ea868f; border: 1px solid #842029; }
.result.waiting { background: #332701; color: #ffda6a; border: 1px solid #332701; }
.instructions { background: #1a1a2e; border: 1px solid #333; border-radius: 8px; padding: 16px; font-size: 13px; line-height: 1.6; }
.instructions code { background: #2a2a3e; padding: 2px 6px; border-radius: 3px; font-size: 12px; }
</style>
</head>
<body>
<h1>Sandbox Origin Validation Test</h1>
<p class="subtitle">Bug bounty #621 — Verify sandboxed frames cannot receive the Uniswap provider</p>
<div class="grid">
<div class="card">
<h3>1. Normal Frame (no sandbox)</h3>
<div class="detail">Expected: origin = http://localhost:*, Uniswap provider = PRESENT</div>
<iframe id="frame-normal" src="child.html"></iframe>
<div id="result-normal" class="result waiting">Waiting for report...</div>
</div>
<div class="card">
<h3>2. Sandboxed (allow-scripts only)</h3>
<div class="detail">Expected: origin = "null", Uniswap provider = ABSENT</div>
<iframe id="frame-sandboxed" src="child.html" sandbox="allow-scripts"></iframe>
<div id="result-sandboxed" class="result waiting">Waiting for report...</div>
</div>
<div class="card">
<h3>3. Sandboxed (allow-scripts + allow-same-origin)</h3>
<div class="detail">Expected: origin = http://localhost:*, Uniswap provider = PRESENT</div>
<iframe id="frame-same-origin" src="child.html" sandbox="allow-scripts allow-same-origin"></iframe>
<div id="result-same-origin" class="result waiting">Waiting for report...</div>
</div>
</div>
<div class="instructions">
<strong>How to use:</strong><br>
1. Serve this directory: <code>python3 -m http.server 8080</code><br>
2. Load the extension in Chrome (webpack dev build via <code>bun start:webpack</code>)<br>
3. Open <code>http://localhost:8080</code> in Chrome<br>
4. All three cards should show their expected results (green = pass, red = fail)<br>
<br>
<strong>Note:</strong> This test detects the <strong>Uniswap provider specifically</strong> via EIP-6963 (<code>rdns: org.uniswap.app</code>),
so other wallet extensions (MetaMask, etc.) won't cause false positives.
</div>
<script>
const expectations = {
normal: { originIsNull: false, hasUniswapProvider: true },
sandboxed: { originIsNull: true, hasUniswapProvider: false },
'same-origin': { originIsNull: false, hasUniswapProvider: true },
};
const received = {};
window.addEventListener('message', (event) => {
if (!event.data || event.data.type !== 'sandbox-test-report') {
return;
}
const { origin, hasUniswapProvider } = event.data;
const isNull = origin === 'null';
// Determine which frame sent this
let frameId = null;
for (const [id, expect] of Object.entries(expectations)) {
if (received[id]) continue;
if (expect.originIsNull === isNull) {
frameId = id;
break;
}
}
// Fallback: match by source iframe
if (!frameId) {
const frames = ['normal', 'sandboxed', 'same-origin'];
for (const id of frames) {
if (received[id]) continue;
const iframe = document.getElementById('frame-' + id);
try {
if (event.source === iframe.contentWindow) {
frameId = id;
break;
}
} catch (e) { /* cross-origin access may throw */ }
}
}
if (!frameId) return;
received[frameId] = true;
const expect = expectations[frameId];
const originPass = expect.originIsNull === isNull;
const providerPass = expect.hasUniswapProvider === hasUniswapProvider;
const allPass = originPass && providerPass;
const el = document.getElementById('result-' + frameId);
el.className = 'result ' + (allPass ? 'pass' : 'fail');
el.innerHTML = `
${allPass ? 'PASS' : 'FAIL'} &mdash;
origin: ${origin} (${originPass ? 'ok' : 'WRONG'}),
Uniswap provider: ${hasUniswapProvider ? 'present' : 'absent'} (${providerPass ? 'ok' : 'WRONG'})
`;
});
// Timeout fallback
setTimeout(() => {
for (const id of Object.keys(expectations)) {
if (!received[id]) {
const el = document.getElementById('result-' + id);
if (el.classList.contains('waiting')) {
el.className = 'result fail';
el.textContent = 'No report received (frame may be blocked)';
}
}
}
}, 5000);
</script>
</body>
</html>
@@ -0,0 +1,39 @@
import { expect } from '@playwright/test'
import { freshExtensionTest as test } from 'e2e/fixtures/extension.fixture'
import { ONE_SECOND_MS } from 'utilities/src/time/time'
import { sleep } from 'utilities/src/time/timing'
test.describe('Basic Extension Setup', () => {
test('extension loads successfully', async ({ context, extensionId }) => {
// Verify extension ID was captured
expect(extensionId).toBeTruthy()
expect(extensionId).toMatch(/^[a-z]{32}$/)
// Wait for extension pages to appear with retry logic
let extensionPages = []
const maxAttempts = 20
for (let i = 0; i < maxAttempts; i++) {
await sleep(ONE_SECOND_MS)
const pages = context.pages()
extensionPages = pages.filter((page) => page.url().includes(`chrome-extension://${extensionId}`))
if (extensionPages.length > 0) {
break
}
}
// Check that we have at least one page open
const pages = context.pages()
expect(pages.length).toBeGreaterThan(0)
// Verify at least one page is from the extension
expect(extensionPages.length).toBeGreaterThan(0)
})
test('background script loads', async ({ context }) => {
// Wait for service worker to load (MV3 extensions use service workers)
await sleep(ONE_SECOND_MS * 2)
const serviceWorkers = context.serviceWorkers()
expect(serviceWorkers.length).toBeGreaterThan(0)
})
})
@@ -0,0 +1,46 @@
import { expect } from '@playwright/test'
import { freshExtensionTest as test } from 'e2e/fixtures/extension.fixture'
import { ONE_SECOND_MS } from 'utilities/src/time/time'
test.describe('Extension Onboarding Flow', () => {
test('onboarding tab opens automatically on fresh install', async ({ context }) => {
// Wait for onboarding tab to open automatically
const onboardingPage = await context.waitForEvent('page', {
predicate: (page) => page.url().includes('onboarding.html'),
timeout: ONE_SECOND_MS * 10,
})
// Verify onboarding page loaded
expect(onboardingPage).toBeTruthy()
await onboardingPage.waitForLoadState('networkidle')
// Check that the onboarding page is loaded
// Look for onboarding-specific elements
await onboardingPage.waitForSelector('[data-testid="onboarding-intro"], button, input', { timeout: 5000 })
// Verify we're on the onboarding page
const title = await onboardingPage.title()
expect(title).toContain('Uniswap Extension')
})
test.skip('sidebar is disabled before onboarding completion', async () => {
// This test requires clicking the actual extension button in Chrome's toolbar,
// which is not easily accessible via Playwright. The expected behavior is that
// clicking the extension button opens onboarding instead of the sidebar when
// the extension is not yet onboarded.
// Direct navigation to sidepanel.html doesn't trigger the redirect logic
// that happens when using the extension button.
})
test('background script initializes on fresh install', async ({ context }) => {
// Wait for any extension page to load
await context.waitForEvent('page', {
predicate: (page) => page.url().includes('chrome-extension://'),
timeout: 5000,
})
// MV3 extensions use service workers
const serviceWorkers = context.serviceWorkers()
expect(serviceWorkers.length).toBeGreaterThan(0)
})
})
@@ -0,0 +1,74 @@
import { expect } from '@playwright/test'
import { onboardedExtensionTest as test } from 'e2e/fixtures/extension.fixture'
import { openExtensionSidebar, waitForBackgroundReady } from 'e2e/utils/extension-helpers'
test.describe('Extension Sidebar', () => {
test.beforeEach(async ({ context }) => {
// Ensure background script is ready
await waitForBackgroundReady(context)
})
test('sidebar loads successfully', async ({ context, extensionId }) => {
// Open the sidebar
const sidebarPage = await openExtensionSidebar(context, extensionId)
await sidebarPage.waitForLoadState('networkidle')
// The sidebar should load without critical errors
const errors: string[] = []
sidebarPage.on('pageerror', (error) => errors.push(error.message))
// Wait for the page to stabilize
await sidebarPage.waitForTimeout(2000)
// Check what state the sidebar is in
const pageContent = await sidebarPage.content()
// The extension could be in different states:
// 1. Main sidebar UI with portfolio/tokens
// 2. Settings page
// 3. Error state
// Check for main UI elements that indicate successful load
// Looking for any common UI elements that appear in the sidebar
const hasTabBar = (await sidebarPage.locator('[role="tablist"]').count()) > 0
const hasTokens = (await sidebarPage.locator('text=/Token|Portfolio|Assets/i').count()) > 0
const hasSettings = (await sidebarPage.locator('button[aria-label*="Settings"]').count()) > 0
const hasAccountInfo = (await sidebarPage.locator('text=/0x[0-9a-fA-F]+/').count()) > 0
// Also check for the main container
const hasMainContent = (await sidebarPage.locator('#root').count()) > 0
// At least one of these states should be present
const isInValidState = hasTabBar || hasTokens || hasSettings || hasAccountInfo || hasMainContent
// For debugging in CI
if (!isInValidState) {
console.log('Sidebar URL:', sidebarPage.url())
console.log('Page has tab bar:', hasTabBar)
console.log('Page has tokens:', hasTokens)
console.log('Page has settings:', hasSettings)
console.log('Page has account info:', hasAccountInfo)
console.log('Page has main content:', hasMainContent)
console.log('Page content length:', pageContent.length)
// Log a sample of the page content for debugging
const bodyText = await sidebarPage
.locator('body')
.innerText()
.catch(() => 'Could not get body text')
console.log('Body text preview:', bodyText.substring(0, 500))
}
expect(isInValidState).toBe(true)
// Verify no critical errors occurred
const criticalErrors = errors.filter(
(e) =>
!e.includes('ResizeObserver') && // Ignore common benign errors
!e.includes('Non-Error promise rejection') &&
!e.includes('Failed to load resource'), // May happen with external resources
)
expect(criticalErrors).toHaveLength(0)
})
})
@@ -0,0 +1,44 @@
import { expect } from '@playwright/test'
import { onboardedExtensionTest as test } from 'e2e/fixtures/extension.fixture'
import { waitForBackgroundReady } from 'e2e/utils/extension-helpers'
test.describe('Wallet Connection to Uniswap', () => {
test('extension is detected by Uniswap app', async ({ context }) => {
// Ensure background script is ready
await waitForBackgroundReady(context)
// Open Uniswap app in a new tab
const uniswapPage = await context.newPage()
await uniswapPage.goto('https://app.uniswap.org', { waitUntil: 'domcontentloaded' })
// Wait a bit for the ethereum provider to be injected
await uniswapPage.waitForTimeout(3000)
// Check that window.ethereum exists
const hasEthereumProvider = await uniswapPage.evaluate(() => {
return typeof window.ethereum !== 'undefined'
})
expect(hasEthereumProvider).toBe(true)
// Check if the provider is properly injected and functional
const providerInfo = await uniswapPage.evaluate(() => {
if (!window.ethereum) {
return null
}
return {
hasRequest: typeof (window.ethereum as any).request === 'function',
hasOn: typeof (window.ethereum as any).on === 'function',
isMetaMask: (window.ethereum as any).isMetaMask || false,
// Get all properties for debugging
properties: Object.getOwnPropertyNames(window.ethereum).sort(),
}
})
expect(providerInfo).not.toBeNull()
expect(providerInfo?.hasRequest).toBe(true)
expect(providerInfo?.hasOn).toBe(true)
// The Uniswap extension presents itself as MetaMask-compatible
expect(providerInfo?.isMetaMask).toBe(true)
})
})
@@ -0,0 +1,27 @@
import type { BrowserContext, Page } from '@playwright/test'
import { sleep } from 'utilities/src/time/timing'
export async function openExtensionSidebar(context: BrowserContext, extensionId: string): Promise<Page> {
const sidebarUrl = `chrome-extension://${extensionId}/sidepanel.html`
const page = await context.newPage()
await page.goto(sidebarUrl)
return page
}
export async function waitForBackgroundReady(context: BrowserContext): Promise<void> {
const maxAttempts = 30
let attempts = 0
while (attempts < maxAttempts) {
// MV3 extensions use service workers instead of background pages
const serviceWorkers = context.serviceWorkers()
if (serviceWorkers.length > 0) {
return
}
await sleep(100)
attempts++
}
throw new Error('Background script failed to initialize within timeout')
}
@@ -0,0 +1,106 @@
import type { BrowserContext, Page } from '@playwright/test'
import { ONE_SECOND_MS } from 'utilities/src/time/time'
import { sleep } from 'utilities/src/time/timing'
const TEST_PASSWORD = 'TestPassword123!'
export async function completeOnboarding(context: BrowserContext, existingOnboardingPage?: Page): Promise<void> {
let onboardingPage: Page
if (existingOnboardingPage) {
onboardingPage = existingOnboardingPage
} else {
// Check if onboarding page is already open
const pages = context.pages()
const existingPage = pages.find((p) => p.url().includes('onboarding.html'))
if (existingPage) {
onboardingPage = existingPage
} else {
// Wait for onboarding page to open
onboardingPage = await context.waitForEvent('page', {
predicate: (page) => page.url().includes('onboarding.html'),
timeout: 10000,
})
}
}
await onboardingPage.waitForLoadState('networkidle')
// Based on thorough analysis, the Create flow is:
// 1. IntroScreen -> Click "Create"
// 2. ClaimUnitagScreen -> Skip unitag
// 3. PasswordCreate -> Set password
// 4. Complete -> Finish
// Step 1: Click "Create" button on intro screen
const createButton = onboardingPage.locator('button:has-text("Create")')
await createButton.waitFor({ state: 'visible', timeout: 10000 })
await createButton.click()
// Wait for navigation
await onboardingPage.waitForTimeout(1000)
// Step 2: Handle unitag/username creation screen - click Skip
// This comes BEFORE password in the create flow
const skipButton = onboardingPage.locator('[data-testid="skip"]')
if (await skipButton.isVisible({ timeout: 5000 })) {
await skipButton.click()
await onboardingPage.waitForTimeout(1000)
}
// Step 3: Set password (PasswordCreate screen)
// Wait for password inputs to be visible
const passwordInputs = onboardingPage.locator('input[type="password"]')
await passwordInputs.first().waitFor({ state: 'visible', timeout: 10000 })
// Fill both password fields
await passwordInputs.first().fill(TEST_PASSWORD)
await passwordInputs.nth(1).fill(TEST_PASSWORD)
// Wait for form validation
await onboardingPage.waitForTimeout(500)
// Click continue button - wait for it to be enabled
const passwordContinue = onboardingPage.locator('button:has-text("Continue")')
await passwordContinue.waitFor({ state: 'visible' })
// Wait for button to be enabled (after password validation)
await onboardingPage.waitForFunction(
() => {
// Find button with "Continue" text
const buttons = document.querySelectorAll('button')
for (const button of buttons) {
if (button.textContent?.includes('Continue') && !button.hasAttribute('disabled')) {
return true
}
}
return false
},
{ timeout: 5000 },
)
await passwordContinue.click()
await onboardingPage.waitForTimeout(1000)
// Step 4: Complete onboarding
// The final screen might have different button text
const completeButton = onboardingPage
.locator('button')
.filter({
hasText: /Get started|Continue|Done|Finish/i,
})
.first()
if (await completeButton.isVisible({ timeout: 5000 })) {
await completeButton.click()
}
// Wait for onboarding to complete and page to close or redirect
await onboardingPage.waitForEvent('close', { timeout: 10000 }).catch(() => {
// Page might redirect instead of closing
})
// Give extension time to initialize after onboarding
await sleep(ONE_SECOND_MS * 2)
}
@@ -0,0 +1,56 @@
/* oxlint-disable typescript/no-explicit-any -- e2e test file */
import type { BrowserContext } from '@playwright/test'
import { sleep } from 'utilities/src/time/timing'
export async function waitForExtensionLoad(
context: BrowserContext,
options?: {
timeout?: number
waitForOnboarding?: boolean
},
// oxlint-disable-next-line typescript/no-explicit-any -- biome-parity: oxlint is stricter here
let onboardingPage: any
while (Date.now() - startTime < timeout) {
// Check all pages
const pages = context.pages()
for (const page of pages) {
const url = page.url()
if (url.startsWith('chrome-extension://')) {
extensionId = url.split('/')[2]
if (url.includes('onboarding')) {
onboardingPage = page
}
break
}
}
// Check service workers (MV3 extensions use service workers, not background pages)
if (!extensionId) {
const workers = context.serviceWorkers()
for (const worker of workers) {
const url = worker.url()
if (url.startsWith('chrome-extension://')) {
extensionId = url.split('/')[2]
break
}
}
}
// If we found the extension and we're waiting for onboarding, keep checking
if (extensionId && options?.waitForOnboarding && !onboardingPage) {
// Continue waiting for onboarding page
} else if (extensionId) {
// We have what we need
break
}
await sleep(100)
}
if (!extensionId) {
throw new Error(`Extension failed to load within ${timeout}ms`)
}
return { extensionId, onboardingPage }
}
+12
View File
@@ -0,0 +1,12 @@
export default defineBackground(() => {
console.log('Lux Exchange extension loaded')
// Listen for messages from popup or content scripts
browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'GET_ACCOUNT') {
// Handle account request
sendResponse({ account: null })
}
return true
})
})
+197
View File
@@ -0,0 +1,197 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
width: 360px;
min-height: 520px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f0f1a;
color: #fff;
}
.container {
display: flex;
flex-direction: column;
min-height: 520px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid #1a1a2e;
}
.logo {
display: flex;
align-items: center;
gap: 8px;
}
.logo-icon {
font-size: 24px;
color: #6366f1;
}
.logo-text {
font-size: 18px;
font-weight: 600;
}
.settings-btn {
background: none;
border: none;
color: #9ca3af;
font-size: 20px;
cursor: pointer;
}
.settings-btn:hover {
color: #fff;
}
.swap-container {
flex: 1;
padding: 16px;
display: flex;
flex-direction: column;
gap: 4px;
}
.input-card {
background: #1a1a2e;
border-radius: 16px;
padding: 16px;
}
.input-header {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
}
.label {
color: #9ca3af;
font-size: 14px;
}
.balance {
color: #6b7280;
font-size: 14px;
}
.input-row {
display: flex;
align-items: center;
gap: 8px;
}
.amount-input {
flex: 1;
background: none;
border: none;
color: #fff;
font-size: 28px;
font-weight: 500;
outline: none;
}
.amount-input::placeholder {
color: #4b5563;
}
.token-btn {
display: flex;
align-items: center;
gap: 8px;
background: #374151;
border: none;
border-radius: 20px;
padding: 8px 12px;
cursor: pointer;
transition: background 0.2s;
}
.token-btn:hover {
background: #4b5563;
}
.token-icon {
width: 24px;
height: 24px;
background: #6366f1;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 600;
color: #fff;
}
.token-symbol {
color: #fff;
font-size: 16px;
font-weight: 600;
}
.dropdown-arrow {
color: #9ca3af;
font-size: 12px;
}
.switch-btn {
align-self: center;
width: 32px;
height: 32px;
background: #374151;
border: 4px solid #0f0f1a;
border-radius: 50%;
color: #fff;
font-size: 16px;
cursor: pointer;
margin: -12px 0;
z-index: 1;
transition: background 0.2s;
}
.switch-btn:hover {
background: #4b5563;
}
.action-btn {
background: #6366f1;
border: none;
border-radius: 16px;
padding: 16px;
color: #fff;
font-size: 16px;
font-weight: 600;
cursor: pointer;
margin-top: 8px;
transition: background 0.2s;
}
.action-btn:hover {
background: #4f46e5;
}
.footer {
padding: 16px;
text-align: center;
border-top: 1px solid #1a1a2e;
}
.footer a {
color: #6366f1;
text-decoration: none;
font-size: 14px;
}
.footer a:hover {
text-decoration: underline;
}
+91
View File
@@ -0,0 +1,91 @@
import { useState } from 'react'
import './App.css'
const TOKENS = [
{ symbol: 'LUX', name: 'Lux' },
{ symbol: 'LETH', name: 'Lux ETH' },
{ symbol: 'LBTC', name: 'Lux BTC' },
{ symbol: 'LUSD', name: 'Lux USD' },
]
export default function App() {
const [inputAmount, setInputAmount] = useState('')
const [inputToken, setInputToken] = useState(TOKENS[0])
const [outputToken, setOutputToken] = useState(TOKENS[1])
const [isConnected, setIsConnected] = useState(false)
const switchTokens = () => {
const temp = inputToken
setInputToken(outputToken)
setOutputToken(temp)
}
return (
<div className="container">
<header className="header">
<div className="logo">
<span className="logo-icon"></span>
<span className="logo-text">Lux Exchange</span>
</div>
<button className="settings-btn"></button>
</header>
<main className="swap-container">
<div className="input-card">
<div className="input-header">
<span className="label">You pay</span>
<span className="balance">Balance: 0.00</span>
</div>
<div className="input-row">
<input
type="text"
className="amount-input"
placeholder="0"
value={inputAmount}
onChange={(e) => setInputAmount(e.target.value)}
/>
<button className="token-btn">
<span className="token-icon">{inputToken.symbol[0]}</span>
<span className="token-symbol">{inputToken.symbol}</span>
<span className="dropdown-arrow"></span>
</button>
</div>
</div>
<button className="switch-btn" onClick={switchTokens}>
</button>
<div className="input-card">
<div className="input-header">
<span className="label">You receive</span>
<span className="balance">Balance: 0.00</span>
</div>
<div className="input-row">
<input
type="text"
className="amount-input"
placeholder="0"
readOnly
/>
<button className="token-btn">
<span className="token-icon">{outputToken.symbol[0]}</span>
<span className="token-symbol">{outputToken.symbol}</span>
<span className="dropdown-arrow"></span>
</button>
</div>
</div>
<button className="action-btn">
{isConnected ? 'Swap' : 'Connect Wallet'}
</button>
</main>
<footer className="footer">
<a href="https://exchange.lux.network" target="_blank" rel="noopener">
Open Full App
</a>
</footer>
</div>
)
}
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lux Exchange</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./index.tsx"></script>
</body>
</html>
@@ -0,0 +1,6 @@
import { createRoot } from 'react-dom/client'
import App from './App'
const container = document.getElementById('root')!
const root = createRoot(container)
root.render(<App />)
+16
View File
@@ -0,0 +1,16 @@
/* oxlint-disable typescript/no-namespace -- required to define process.env type */
declare global {
namespace NodeJS {
// All process.env values used by this package should be listed here
interface ProcessEnv {
NODE_ENV?: 'development' | 'production' | 'test'
BUILD_ENV?: string
CI?: string
VERSION?: string
WDYR?: string
}
}
}
export {}
+31
View File
@@ -0,0 +1,31 @@
import minimal from '@uniswap/eslint-config/minimal'
export default [
{
ignores: [
'node_modules',
'dist',
'.turbo',
'build',
'jest.config.js',
'webpack.config.js',
'webpack.dev.config.js',
'webpack-plugins/**',
'manifest.json',
'.nx',
'.output/**',
'.wxt/**',
'wxt.config.ts',
'jest-setup.js',
],
},
...minimal,
{
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
]
+153
View File
@@ -0,0 +1,153 @@
import 'utilities/jest-package-mocks'
import 'uniswap/jest-package-mocks'
import 'wallet/jest-package-mocks'
import 'config/jest-presets/ui/ui-package-mocks'
import 'react-native-gesture-handler/jestSetup'
import { chrome } from 'jest-chrome'
import { AppearanceSettingType } from 'uniswap/src/features/appearance/slice'
process.env.IS_UNISWAP_EXTENSION = true
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()
const MOCK_LANGUAGE = 'en-US'
global.chrome = {
...chrome,
i18n: {
...global.chrome.i18n,
getUILanguage: jest.fn().mockReturnValue(MOCK_LANGUAGE),
},
storage: {
...chrome.storage,
local: {
...chrome.storage.local,
addListener: jest.fn(),
},
session: {
get: jest.fn().mockImplementation((_keys, callback) => {
if (callback) {
callback({})
}
return Promise.resolve({})
}),
set: jest.fn().mockImplementation((_items, callback) => {
if (callback) {
callback()
}
return Promise.resolve()
}),
remove: jest.fn().mockImplementation((_keys, callback) => {
if (callback) {
callback()
}
return Promise.resolve()
}),
clear: jest.fn().mockImplementation((callback) => {
if (callback) {
callback()
}
return Promise.resolve()
}),
},
},
}
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('uniswap/src/features/appearance/hooks', () => {
return {
useCurrentAppearanceSetting: () => mockAppearanceSetting,
useSelectedColorScheme: () => 'light',
}
})
// Mock IntersectionObserver for Tamagui's useElementLayout
const IntersectionObserverMock = jest.fn().mockImplementation((callback) => ({
observe: jest.fn((element) => {
// Immediately call the callback with a mock entry
if (callback && element) {
callback([
{
target: element,
isIntersecting: true,
intersectionRatio: 1,
boundingClientRect: {
x: 0,
y: 0,
width: 100,
height: 100,
top: 0,
right: 100,
bottom: 100,
left: 0,
},
intersectionRect: {
x: 0,
y: 0,
width: 100,
height: 100,
top: 0,
right: 100,
bottom: 100,
left: 0,
},
rootBounds: null,
time: 0,
},
])
}
}),
unobserve: jest.fn(),
disconnect: jest.fn(),
takeRecords: jest.fn().mockReturnValue([]),
root: null,
rootMargin: '',
thresholds: [],
}))
global.IntersectionObserver = IntersectionObserverMock
+39
View File
@@ -0,0 +1,39 @@
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',
testMatch: ['<rootDir>/src/**/*.(spec|test).[jt]s?(x)', '<rootDir>/config/**/*.(spec|test).[jt]s?(x)'],
testPathIgnorePatterns: [...preset.testPathIgnorePatterns, '<rootDir>/e2e/'],
collectCoverageFrom: [
'src/app/**/*.{js,ts,tsx}',
'src/background/**/*.{js,ts,tsx}',
'src/contentScript/**/*.{js,ts,tsx}',
'config/**/*.{js,ts,tsx}',
'!src/**/*.stories.**',
'!**/node_modules/**',
],
coverageThreshold: {
global: {
lines: 0,
},
},
setupFiles: ['../../config/jest-presets/jest/setup.js', './jest-setup.js'],
}
+145
View File
@@ -0,0 +1,145 @@
{
"name": "@uniswap/extension",
"version": "0.0.0",
"browserslist": "last 2 chrome versions",
"dependencies": {
"@apollo/client": "3.11.10",
"@datadog/browser-rum": "5.23.3",
"@ethersproject/bignumber": "5.7.0",
"@ethersproject/providers": "5.7.2",
"@ethersproject/strings": "5.7.0",
"@ethersproject/wordlists": "5.7.0",
"@metamask/rpc-errors": "6.2.1",
"@reduxjs/toolkit": "1.9.3",
"@svgr/webpack": "8.0.1",
"@tamagui/core": "1.136.1",
"@tanstack/react-query": "5.90.20",
"@types/uuid": "9.0.1",
"@uniswap/analytics-events": "2.43.0",
"@uniswap/client-notification-service": "0.0.11",
"@uniswap/sdk-core": "7.12.1",
"@uniswap/universal-router-sdk": "4.33.0",
"@uniswap/v3-sdk": "3.29.1",
"@uniswap/v4-sdk": "1.29.1",
"@universe/api": "workspace:^",
"@universe/gating": "workspace:^",
"@universe/notifications": "workspace:^",
"@universe/sessions": "workspace:^",
"@wxt-dev/module-react": "1.1.3",
"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": "19.0.3",
"react-dom": "19.0.3",
"react-i18next": "14.1.0",
"react-native": "0.79.5",
"react-native-gesture-handler": "2.24.0",
"react-native-reanimated": "3.19.3",
"react-native-svg": "15.13.0",
"react-native-web": "0.19.13",
"react-qr-code": "2.0.12",
"react-redux": "8.0.5",
"react-router": "7.6.3",
"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",
"wallet": "workspace:^",
"wxt": "0.20.8",
"zod": "4.3.6",
"zustand": "5.0.6"
},
"devDependencies": {
"@playwright/test": "1.58.2",
"@pmmmwh/react-refresh-webpack-plugin": "0.5.11",
"@testing-library/dom": "10.4.0",
"@testing-library/react": "16.3.0",
"@types/chrome": "0.0.304",
"@types/jest": "29.5.14",
"@types/ms": "0.7.31",
"@types/node": "22.13.1",
"@types/react": "19.0.10",
"@types/react-dom": "19.0.6",
"@types/redux-logger": "3.0.9",
"@types/redux-persist-webextension-storage": "1.0.3",
"@types/ua-parser-js": "0.7.31",
"@typescript/native-preview": "7.0.0-dev.20260311.1",
"@uniswap/eslint-config": "workspace:^",
"@welldone-software/why-did-you-render": "10.0.1",
"clean-webpack-plugin": "4.0.0",
"concurrently": "8.2.2",
"copy-webpack-plugin": "11.0.0",
"css-loader": "6.11.0",
"esbuild-loader": "3.2.0",
"eslint": "10.0.2",
"jest": "29.7.0",
"jest-chrome": "0.8.0",
"jest-environment-jsdom": "29.5.0",
"jest-extended": "4.0.2",
"mini-css-extract-plugin": "2.9.1",
"ms": "2.1.3",
"react-refresh": "0.14.0",
"serve": "14.2.4",
"style-loader": "3.3.2",
"swc-loader": "0.2.6",
"tamagui-loader": "1.136.1",
"typescript": "5.8.3",
"webpack": "5.90.0",
"webpack-cli": "5.1.4",
"webpack-dev-server": "4.15.1"
},
"private": true,
"scripts": {
"build:firefox": "nx build:firefox extension",
"build:production": "nx build:production extension",
"build:wxt": "nx build:wxt extension",
"check:circular": "nx check:circular extension",
"check:deps:usage": "nx check:deps:usage extension",
"clean": "nx clean extension",
"dev": "nx dev extension",
"dev:firefox": "nx dev:firefox extension",
"env:local:download": "nx env:local:download extension",
"env:local:upload": "nx env:local:upload extension",
"lint:eslint": "nx lint:eslint extension",
"lint:eslint:fix": "nx lint:eslint:fix extension",
"lint": "nx lint extension",
"lint:fix": "nx lint:fix extension",
"postinstall": "nx postinstall extension",
"prepare": "nx prepare extension",
"snapshots": "nx snapshots extension",
"start": "nx start extension",
"start:absolute": "nx start:absolute extension",
"start:absolute:mac": "nx start:absolute:mac extension",
"start:absolute:windows": "nx start:absolute:windows extension",
"start:webpack": "nx start:webpack extension",
"start:webpack:absolute": "nx start:webpack:absolute extension",
"test": "nx test extension",
"typecheck": "nx typecheck extension",
"typecheck:tsgo": "nx typecheck:tsgo extension",
"zip": "nx zip extension",
"zip:firefox": "nx zip:firefox extension",
"build:e2e": "nx build:e2e extension",
"playwright:test": "nx playwright:test extension",
"playwright:test:smoke": "nx playwright:test:smoke extension",
"playwright:ui": "nx playwright:ui extension",
"e2e": "nx e2e extension",
"e2e:smoke": "nx e2e:smoke extension",
"e2e:ui": "nx e2e:ui extension",
"validate:build": "nx validate:build extension",
"validate:build:dev": "nx validate:build:dev extension",
"validate:build:prod": "nx validate:build:prod extension",
"oxlint": "nx oxlint extension",
"oxlint:fix": "nx oxlint:fix extension",
"oxfmt": "nx oxfmt extension",
"oxfmt:fix": "nx oxfmt:fix extension"
},
"nx": {
"includedScripts": []
}
}
+194
View File
@@ -0,0 +1,194 @@
{
"tags": ["scope:extension", "type:app"],
"targets": {
"build": {
"executor": "nx:noop",
"dependsOn": ["^build", "prepare"]
},
"build:firefox": {
"command": "wxt build -b firefox",
"options": {
"cwd": "{projectRoot}"
}
},
"build:production": {
"command": "webpack --node-env=production --env BUILD_ENV=prod BUILD_NUM=${BUILD_NUM:-0}",
"options": {
"cwd": "{projectRoot}"
}
},
"build:wxt": {
"command": "wxt build",
"options": {
"cwd": "{projectRoot}"
}
},
"check:circular": {
"command": "bunx madge --circular ./src/entrypoints/sidepanel/main.tsx ./src/entrypoints/onboarding/main.tsx ./src/entrypoints/unitagClaim/main.tsx",
"options": {
"cwd": "{projectRoot}"
}
},
"check:deps:usage": {},
"clean": {
"command": "wxt clean",
"options": {
"cwd": "{projectRoot}"
}
},
"dev": {
"command": "wxt",
"options": {
"cwd": "{projectRoot}"
}
},
"dev:firefox": {
"command": "wxt -b firefox",
"options": {
"cwd": "{projectRoot}"
}
},
"env:local:download": {
"command": "bash ../../scripts/downloadEnvLocal.sh m4dhqfltt3dokkqi3hqwigmf2a .env",
"options": {
"cwd": "{projectRoot}"
}
},
"env:local:upload": {
"command": "bash ../../scripts/uploadEnvLocal.sh m4dhqfltt3dokkqi3hqwigmf2a .env",
"options": {
"cwd": "{projectRoot}"
}
},
"lint": {},
"lint:fix": {},
"check": {},
"check:fix": {},
"lint:eslint": {
"command": "NODE_OPTIONS='--max_old_space_size=8192 --max-semi-space-size=256' eslint --cache --cache-location node_modules/.cache/eslint/ --max-warnings=0 .",
"options": {
"cwd": "{projectRoot}"
}
},
"lint:eslint:fix": {
"command": "NODE_OPTIONS='--max_old_space_size=8192 --max-semi-space-size=256' eslint --cache --cache-location node_modules/.cache/eslint/ . --fix",
"options": {
"cwd": "{projectRoot}"
}
},
"postinstall": {
"command": "wxt prepare",
"options": {
"cwd": "{projectRoot}"
}
},
"prepare": {
"command": "wxt prepare",
"options": {
"cwd": "{projectRoot}"
},
"cache": true,
"inputs": [
"{projectRoot}/wxt.config.ts",
"{projectRoot}/config/**",
"{projectRoot}/src/entrypoints/**/*",
"{projectRoot}/package.json"
],
"outputs": ["{projectRoot}/.wxt/types", "{projectRoot}/.wxt/tsconfig.json", "{projectRoot}/.wxt/wxt.d.ts"]
},
"snapshots": {
"command": "jest -u",
"options": {
"cwd": "{projectRoot}"
}
},
"start": {
"command": "wxt",
"options": {
"cwd": "{projectRoot}"
}
},
"start:absolute": {
"command": "bun run start:absolute:mac",
"options": {
"cwd": "{projectRoot}"
}
},
"start:absolute:mac": {
"command": "WXT_ABSOLUTE_OUTDIR=/Users/Shared/stretch wxt",
"options": {
"cwd": "{projectRoot}"
}
},
"start:absolute:windows": {
"command": "WXT_ABSOLUTE_OUTDIR=C:/ProgramData/stretch wxt",
"options": {
"cwd": "{projectRoot}"
}
},
"command": "webpack --node-env=production --env BUILD_ENV=dev BUILD_NUM=0",
"options": {
"cwd": "{projectRoot}"
},
"inputs": ["dependencies", "sourceFiles", "tsConfig"],
"outputs": ["{projectRoot}/dist", "{projectRoot}/build", "{projectRoot}/.next", "{projectRoot}/types"],
"cache": true,
"dependsOn": ["^build", "prepare"]
},
"playwright:test": {
"command": "playwright test --config=e2e/config/playwright.config.ts",
"options": {
"cwd": "{projectRoot}"
}
},
"playwright:test:smoke": {
"command": "playwright test --config=e2e/config/playwright.config.ts e2e/tests/smoke/",
"options": {
"cwd": "{projectRoot}"
}
},
"playwright:ui": {
"command": "playwright test --ui --config=e2e/config/playwright.config.ts",
"options": {
"cwd": "{projectRoot}"
}
},
"e2e": {
"command": "nx playwright:test extension",
"dependsOn": ["build:e2e"]
},
"e2e:smoke": {
"command": "nx playwright:test:smoke extension",
"dependsOn": ["build:e2e"]
},
"e2e:ui": {
"command": "nx playwright:ui extension",
"dependsOn": ["build:e2e"]
},
"validate:build": {
"command": "bunx tsx scripts/validateBuildOutput.ts",
"options": {
"cwd": "{projectRoot}"
},
"dependsOn": ["build:wxt"]
},
"validate:build:dev": {
"command": "bunx tsx scripts/validateBuildOutput.ts --dev",
"options": {
"cwd": "{projectRoot}"
}
},
"validate:build:prod": {
"command": "bunx tsx scripts/validateBuildOutput.ts --prod",
"options": {
"cwd": "{projectRoot}"
},
"dependsOn": ["build:wxt"]
},
"oxfmt": {},
"oxfmt:fix": {},
"oxlint": {},
"oxlint:fix": {},
"oxlint:type-aware": {}
}
}
@@ -0,0 +1,88 @@
/* oxlint-disable no-console -- CLI script requires console output */
/**
* Validates extension build output for common issues.
*
* Checks for:
* - __vite_browser_external markers (indicates Node.js modules were externalized)
*/
import * as fs from 'node:fs'
import * as path from 'node:path'
const BUILD_DIRS = [
'.output/chrome-mv3-dev', // dev build (bun extension start)
'.output/chrome-mv3', // production build (bun extension build:production)
]
// Can be overridden via CLI arg: --dev or --prod
const args = process.argv.slice(2)
const devOnly = args.includes('--dev')
const prodOnly = args.includes('--prod')
// Support WXT_ABSOLUTE_OUTDIR for absolute path builds (e.g., bun extension start:absolute)
const absoluteOutDir = process.env['WXT_ABSOLUTE_OUTDIR']
const dirsToCheck = absoluteOutDir
? [absoluteOutDir]
: devOnly
? ['.output/chrome-mv3-dev']
: prodOnly
? ['.output/chrome-mv3']
: BUILD_DIRS
const BACKGROUND_SCRIPT = 'background.js'
// Patterns that indicate problematic externalization
const FORBIDDEN_PATTERNS = [
{
pattern: '__vite_browser_external',
message:
'Node.js module externalization detected. A dependency is importing Node.js built-ins (like "util", "fs", etc.) that cannot run in the browser. Check recent import changes in background script entry points.',
},
]
function validateBuild(): boolean {
let buildDir: string | null = null
// Find existing build directory
for (const dir of dirsToCheck) {
// For absolute paths, use directly; for relative paths, resolve from project root
const fullPath = path.isAbsolute(dir) ? dir : path.join(__dirname, '..', dir)
if (fs.existsSync(fullPath)) {
buildDir = fullPath
break
}
}
if (!buildDir) {
console.error('No build output found. Run `bun build:wxt` first.')
process.exit(1)
}
const backgroundPath = path.join(buildDir, BACKGROUND_SCRIPT)
if (!fs.existsSync(backgroundPath)) {
console.error(`Background script not found at ${backgroundPath}`)
process.exit(1)
}
const content = fs.readFileSync(backgroundPath, 'utf-8')
let hasErrors = false
for (const { pattern, message } of FORBIDDEN_PATTERNS) {
if (content.includes(pattern)) {
console.error(`\n❌ BUILD VALIDATION FAILED`)
console.error(`Pattern found: "${pattern}"`)
console.error(`\n${message}\n`)
hasErrors = true
}
}
if (hasErrors) {
process.exit(1)
}
console.log('✅ Build validation passed')
return true
}
validateBuild()
+56
View File
@@ -0,0 +1,56 @@
body,
html {
height: 100%;
max-width: 100vw;
font-feature-settings: 'liga' 0;
font-variant-ligatures: no-contextual;
}
/* Theme-aware background colors using Tamagui theme classes */
.t_light body,
.t_light html {
background-color: #fff;
}
.t_dark body,
.t_dark html {
background-color: #131313;
}
#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%;
}
}
@keyframes cloud-float-animation {
0% {
transform: translateY(-8px);
}
50% {
transform: translateY(8px);
}
100% {
transform: translateY(-8px);
}
}
+23
View File
@@ -0,0 +1,23 @@
import { ApolloProvider } from '@apollo/client/react/context'
import { PropsWithChildren } from 'react'
import { localStorage } from 'redux-persist-webextension-storage'
import { getReduxStore } from 'src/store/store'
// oxlint-disable-next-line no-restricted-imports -- Direct wallet import needed for Apollo client setup in extension context
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,
reduxStore: getReduxStore(),
})
if (!apolloClient) {
return <></>
}
return <ApolloProvider client={apolloClient}>{children}</ApolloProvider>
}
@@ -0,0 +1,337 @@
import React from 'react'
import { AutoLockProvider } from 'src/app/components/AutoLockProvider'
import { render } from 'src/test/test-utils'
import { FiatCurrency } from 'uniswap/src/features/fiatCurrency/constants'
import { Language } from 'uniswap/src/features/language/constants'
import { DeviceAccessTimeout } from 'uniswap/src/features/settings/constants'
import { logger } from 'utilities/src/logger/logger'
// Mock dependencies
jest.mock('uniswap/src/extension/useIsChromeWindowFocused')
jest.mock('utilities/src/logger/logger')
jest.mock('src/app/hooks/useIsWalletUnlocked', () => ({
useIsWalletUnlocked: jest.fn(),
isWalletUnlocked: null,
}))
// Import mocked modules
import { useIsWalletUnlocked } from 'src/app/hooks/useIsWalletUnlocked'
import { useIsChromeWindowFocused } from 'uniswap/src/extension/useIsChromeWindowFocused'
const mockUseIsChromeWindowFocused = jest.mocked(useIsChromeWindowFocused)
const mockUseIsWalletUnlocked = jest.mocked(useIsWalletUnlocked)
const mockLogger = jest.mocked(logger)
// Mock chrome.alarms API
const mockChromeAlarms = {
create: jest.fn(),
clear: jest.fn(),
}
global.chrome = {
...global.chrome,
alarms: mockChromeAlarms as unknown as typeof chrome.alarms,
}
// Helper function
const renderAutoLockProvider = (deviceAccessTimeout: DeviceAccessTimeout) => {
return render(
<AutoLockProvider>
<div>Test</div>
</AutoLockProvider>,
{
preloadedState: {
userSettings: {
currentLanguage: Language.English,
currentCurrency: FiatCurrency.UnitedStatesDollar,
hideSmallBalances: true,
hideSpamTokens: true,
hapticsEnabled: true,
deviceAccessTimeout,
},
},
},
)
}
const simulateFocusChange = (component: ReturnType<typeof render>) => (fromFocused: boolean, toFocused: boolean) => {
mockUseIsChromeWindowFocused.mockReturnValue(fromFocused)
const { rerender } = component
mockUseIsChromeWindowFocused.mockReturnValue(toFocused)
rerender(<AutoLockProvider />)
}
describe('AutoLockProvider', () => {
beforeEach(() => {
jest.clearAllMocks()
mockUseIsChromeWindowFocused.mockReturnValue(true)
mockUseIsWalletUnlocked.mockReturnValue(true)
mockLogger.debug.mockImplementation(() => {})
mockLogger.error.mockImplementation(() => {})
mockChromeAlarms.create.mockImplementation(() => {})
mockChromeAlarms.clear.mockImplementation(() => {})
})
describe('mount behavior', () => {
it('should clear alarm on mount', () => {
renderAutoLockProvider(DeviceAccessTimeout.FiveMinutes)
expect(mockChromeAlarms.clear).toHaveBeenCalledWith('AutoLockAlarm')
})
it('should always render children', () => {
const { container } = renderAutoLockProvider(DeviceAccessTimeout.FiveMinutes)
expect(container.textContent).toBe('Test')
})
})
describe('unmount behavior', () => {
it('should not schedule alarm on unmount (handled by background port disconnect)', () => {
const { unmount } = renderAutoLockProvider(DeviceAccessTimeout.FiveMinutes)
unmount()
// Unmount no longer schedules alarm - this is handled by background script
expect(mockChromeAlarms.create).not.toHaveBeenCalled()
})
})
describe('focus change behavior (while sidebar is open)', () => {
it('should schedule alarm when window loses focus and wallet is unlocked', () => {
const component = renderAutoLockProvider(DeviceAccessTimeout.FiveMinutes)
mockChromeAlarms.create.mockClear() // Clear the mount call
simulateFocusChange(component)(true, false)
expect(mockChromeAlarms.create).toHaveBeenCalledWith('AutoLockAlarm', {
delayInMinutes: 5,
})
})
it('should clear alarm when window regains focus', () => {
const component = renderAutoLockProvider(DeviceAccessTimeout.FiveMinutes)
mockChromeAlarms.clear.mockClear() // Clear the mount call
// First lose focus (creates alarm)
simulateFocusChange(component)(true, false)
expect(mockChromeAlarms.create).toHaveBeenCalled()
// Then regain focus
simulateFocusChange(component)(false, true)
expect(mockChromeAlarms.clear).toHaveBeenCalledWith('AutoLockAlarm')
})
it('should not schedule alarm when window loses focus and wallet is locked', () => {
mockUseIsWalletUnlocked.mockReturnValue(false)
const component = renderAutoLockProvider(DeviceAccessTimeout.FiveMinutes)
mockChromeAlarms.create.mockClear() // Clear the mount call
simulateFocusChange(component)(true, false)
expect(mockChromeAlarms.create).not.toHaveBeenCalled()
})
it('should not schedule alarm when window loses focus and timeout is Never', () => {
const component = renderAutoLockProvider(DeviceAccessTimeout.Never)
mockChromeAlarms.create.mockClear() // Clear the mount call
simulateFocusChange(component)(true, false)
expect(mockChromeAlarms.create).not.toHaveBeenCalled()
})
})
describe('wallet state changes', () => {
it('should clear alarm when wallet becomes locked', () => {
mockUseIsWalletUnlocked.mockReturnValue(true)
const { rerender } = renderAutoLockProvider(DeviceAccessTimeout.FiveMinutes)
// Clear the initial mount call
mockChromeAlarms.clear.mockClear()
// Wallet becomes locked
mockUseIsWalletUnlocked.mockReturnValue(false)
rerender(<AutoLockProvider />)
expect(mockChromeAlarms.clear).toHaveBeenCalledWith('AutoLockAlarm')
})
it('should clear alarm when wallet becomes unlocked', () => {
mockUseIsWalletUnlocked.mockReturnValue(false)
const { rerender } = renderAutoLockProvider(DeviceAccessTimeout.FiveMinutes)
// Clear the initial mount call
mockChromeAlarms.clear.mockClear()
// Wallet becomes unlocked
mockUseIsWalletUnlocked.mockReturnValue(true)
rerender(<AutoLockProvider />)
expect(mockChromeAlarms.clear).toHaveBeenCalledWith('AutoLockAlarm')
})
it('should not clear alarm on initial render (only mount clear)', () => {
mockUseIsWalletUnlocked.mockReturnValue(true)
renderAutoLockProvider(DeviceAccessTimeout.FiveMinutes)
// Only the mount clear should have been called
expect(mockChromeAlarms.clear).toHaveBeenCalledTimes(1)
})
})
describe('combined scenarios', () => {
it('should handle mount -> unmount -> remount correctly', () => {
// First mount
const { unmount } = renderAutoLockProvider(DeviceAccessTimeout.FiveMinutes)
expect(mockChromeAlarms.clear).toHaveBeenCalledTimes(1)
// Unmount (alarm scheduling now handled in background)
unmount()
// Second mount (clears alarm)
renderAutoLockProvider(DeviceAccessTimeout.FiveMinutes)
expect(mockChromeAlarms.clear).toHaveBeenCalledTimes(2)
})
it('should handle wallet unlock during mounted state', () => {
mockUseIsWalletUnlocked.mockReturnValue(false)
const { rerender } = renderAutoLockProvider(DeviceAccessTimeout.FiveMinutes)
mockChromeAlarms.clear.mockClear()
// Wallet unlocks while mounted
mockUseIsWalletUnlocked.mockReturnValue(true)
rerender(<AutoLockProvider />)
// Should clear alarm due to wallet state change
expect(mockChromeAlarms.clear).toHaveBeenCalled()
})
it('should handle wallet lock during mounted state', () => {
mockUseIsWalletUnlocked.mockReturnValue(true)
const { rerender } = renderAutoLockProvider(DeviceAccessTimeout.FiveMinutes)
mockChromeAlarms.clear.mockClear()
// Wallet locks while mounted
mockUseIsWalletUnlocked.mockReturnValue(false)
rerender(<AutoLockProvider />)
// Should clear alarm due to wallet state change
expect(mockChromeAlarms.clear).toHaveBeenCalled()
})
})
describe('error handling', () => {
it('should handle chrome.alarms.create errors gracefully', () => {
const error = new Error('Permission denied')
mockChromeAlarms.create.mockImplementationOnce(() => {
throw error
})
const component = renderAutoLockProvider(DeviceAccessTimeout.FiveMinutes)
mockChromeAlarms.create.mockClear()
// This should not throw, error should be logged
expect(() => {
simulateFocusChange(component)(true, false)
}).not.toThrow()
expect(mockLogger.error).toHaveBeenCalledWith(error, {
tags: { file: 'AutoLockProvider', function: 'createAutoLockAlarm' },
extra: { delayInMinutes: 5 },
})
})
it('should handle chrome.alarms.clear errors gracefully', () => {
const error = new Error('Permission denied')
mockChromeAlarms.clear.mockImplementationOnce(() => {
throw error
})
// This should not throw, error should be logged
expect(() => {
renderAutoLockProvider(DeviceAccessTimeout.FiveMinutes)
}).not.toThrow()
expect(mockLogger.error).toHaveBeenCalledWith(error, {
tags: { file: 'AutoLockProvider', function: 'clearAutoLockAlarm' },
extra: { reason: 'Cleared auto-lock alarm (sidebar opened)' },
})
})
it('should continue to function after chrome.alarms errors', () => {
// First call fails
mockChromeAlarms.clear.mockImplementationOnce(() => {
throw new Error('Permission denied')
})
const component = renderAutoLockProvider(DeviceAccessTimeout.FiveMinutes)
// Clear the error and mock calls
mockLogger.error.mockClear()
mockChromeAlarms.clear.mockClear()
mockChromeAlarms.create.mockClear()
// Subsequent calls should still work
simulateFocusChange(component)(true, false)
expect(mockChromeAlarms.create).toHaveBeenCalledWith('AutoLockAlarm', {
delayInMinutes: 5,
})
expect(mockLogger.error).not.toHaveBeenCalled()
})
})
describe('edge cases', () => {
it('should handle rapid mount/unmount cycles', () => {
const { unmount: unmount1 } = renderAutoLockProvider(DeviceAccessTimeout.FiveMinutes)
unmount1()
const { unmount: unmount2 } = renderAutoLockProvider(DeviceAccessTimeout.FiveMinutes)
unmount2()
// Should have cleared alarm twice (once per mount)
expect(mockChromeAlarms.clear).toHaveBeenCalledTimes(2)
// No create calls because unmount scheduling is handled in background
expect(mockChromeAlarms.create).not.toHaveBeenCalled()
})
it('should handle rapid focus changes', () => {
const component = renderAutoLockProvider(DeviceAccessTimeout.FiveMinutes)
mockChromeAlarms.create.mockClear()
mockChromeAlarms.clear.mockClear()
// Lose focus
simulateFocusChange(component)(true, false)
expect(mockChromeAlarms.create).toHaveBeenCalledTimes(1)
// Regain focus
simulateFocusChange(component)(false, true)
expect(mockChromeAlarms.clear).toHaveBeenCalledTimes(1)
// Lose focus again
simulateFocusChange(component)(true, false)
expect(mockChromeAlarms.create).toHaveBeenCalledTimes(2)
})
it('should prioritize wallet state changes over focus changes (race condition)', () => {
mockUseIsWalletUnlocked.mockReturnValue(true)
mockUseIsChromeWindowFocused.mockReturnValue(true)
const { rerender } = renderAutoLockProvider(DeviceAccessTimeout.FiveMinutes)
mockChromeAlarms.clear.mockClear()
mockChromeAlarms.create.mockClear()
// Simulate simultaneous wallet lock + focus loss
mockUseIsWalletUnlocked.mockReturnValue(false)
mockUseIsChromeWindowFocused.mockReturnValue(false)
rerender(<AutoLockProvider />)
// Should only clear alarm (wallet state change priority), not schedule
expect(mockChromeAlarms.clear).toHaveBeenCalledTimes(1)
expect(mockChromeAlarms.create).not.toHaveBeenCalled()
})
})
})
@@ -0,0 +1,98 @@
import { PropsWithChildren, useEffect, useRef } from 'react'
import { useSelector } from 'react-redux'
import { useIsWalletUnlocked } from 'src/app/hooks/useIsWalletUnlocked'
import { useIsChromeWindowFocused } from 'uniswap/src/extension/useIsChromeWindowFocused'
import { selectDeviceAccessTimeoutMinutes } from 'uniswap/src/features/settings/selectors'
import { logger } from 'utilities/src/logger/logger'
export const AUTO_LOCK_ALARM_NAME = 'AutoLockAlarm'
/**
* Helper to safely clear the auto-lock alarm with error handling
*/
function clearAutoLockAlarm(reason: string): void {
try {
chrome.alarms.clear(AUTO_LOCK_ALARM_NAME)
logger.debug('AutoLockProvider', 'clearAutoLockAlarm', reason)
} catch (error) {
logger.error(error, {
tags: { file: 'AutoLockProvider', function: 'clearAutoLockAlarm' },
extra: { reason },
})
}
}
/**
* Helper to safely create the auto-lock alarm with error handling
*/
function createAutoLockAlarm(delayInMinutes: number): void {
try {
chrome.alarms.create(AUTO_LOCK_ALARM_NAME, { delayInMinutes })
logger.debug('AutoLockProvider', 'createAutoLockAlarm', `Scheduled auto-lock alarm for ${delayInMinutes} minutes`)
} catch (error) {
logger.error(error, {
tags: { file: 'AutoLockProvider', function: 'createAutoLockAlarm' },
extra: { delayInMinutes },
})
}
}
/**
* AutoLockProvider schedules chrome alarms to automatically lock the wallet
* after the configured timeout period when the sidebar is not focused.
*
* Uses chrome.alarms API which persists even when the extension is closed,
* ensuring reliable auto-lock behavior.
*/
export function AutoLockProvider({ children }: PropsWithChildren): JSX.Element {
const delayInMinutes = useSelector(selectDeviceAccessTimeoutMinutes)
const isWalletUnlocked = useIsWalletUnlocked()
const isChromeWindowFocused = useIsChromeWindowFocused()
// Ref to track previous focus state
const prevFocusedRef = useRef(true)
// Ref to track previous unlock state
const prevUnlockedRef = useRef<boolean | null>(null)
// On mount: Clear any existing alarm (sidebar just opened)
useEffect(() => {
clearAutoLockAlarm('Cleared auto-lock alarm (sidebar opened)')
}, [])
useEffect(() => {
// Skip if timeout not configured (Never)
if (delayInMinutes === undefined) {
clearAutoLockAlarm('Cleared auto-lock alarm (timeout not configured)')
return
}
const prevFocused = prevFocusedRef.current
const prevUnlocked = prevUnlockedRef.current
prevFocusedRef.current = isChromeWindowFocused
prevUnlockedRef.current = isWalletUnlocked
// Skip first render for unlock state
if (prevUnlocked === null) {
return
}
// Clear alarm when wallet state changes (locked or unlocked)
if (prevUnlocked !== isWalletUnlocked) {
clearAutoLockAlarm(`Cleared auto-lock alarm (wallet ${isWalletUnlocked ? 'unlocked' : 'locked'})`)
return
}
// When window loses focus AND wallet is unlocked: schedule alarm
if (prevFocused && !isChromeWindowFocused && isWalletUnlocked) {
createAutoLockAlarm(delayInMinutes)
return
}
// When window regains focus: clear alarm
if (!prevFocused && isChromeWindowFocused) {
clearAutoLockAlarm('Cleared auto-lock alarm (window focused)')
}
}, [isChromeWindowFocused, isWalletUnlocked, delayInMinutes])
return <>{children}</>
}
@@ -0,0 +1,13 @@
import { PropsWithChildren } from 'react'
import { useRouteError } from 'react-router'
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
}
@@ -0,0 +1,38 @@
import { forwardRef } from 'react'
import { Input as TamaguiInput, InputProps as TamaguiInputProps } from 'ui/src'
import { inputStyles } from 'ui/src/components/input/utils'
import { fonts } from 'ui/src/theme/fonts'
export type InputProps = {
large?: boolean
hideInput?: boolean
centered?: boolean
} & TamaguiInputProps
export type Input = TamaguiInput
export const Input = forwardRef<Input, InputProps>(function InputInner(
{ large = false, hideInput = false, centered = false, ...rest }: InputProps,
ref,
): JSX.Element {
return (
<TamaguiInput
ref={ref}
backgroundColor={large ? '$surface1' : '$surface2'}
borderColor="$surface3"
borderRadius="$rounded16"
borderWidth="$spacing1"
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}
/>
)
})
@@ -0,0 +1,116 @@
import { forwardRef } from 'react'
import { useTranslation } from 'react-i18next'
import { TextInput } from 'react-native'
import { Input, InputProps } from 'src/app/components/Input'
import { useShouldShowBiometricUnlock } from 'src/app/features/biometricUnlock/useShouldShowBiometricUnlock'
import { Flex, FlexProps, IconProps, Text, TouchableArea } from 'ui/src'
import { Eye, EyeOff, Fingerprint } from 'ui/src/components/icons'
import { getPasswordStrengthTextAndColor, PasswordStrength } 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
hideBiometrics?: 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 && (
<TouchableArea
backgroundColor="$transparent"
hoverStyle={hoverStyle}
position="absolute"
pressStyle={hoverStyle}
right="$spacing8"
p="$spacing12"
onPress={(): void => onToggleHideInput(!hideInput)}
>
{hideInput ? <Eye {...iconProps} /> : <EyeOff {...iconProps} />}
</TouchableArea>
)
)}
</Flex>
)
})
export const PasswordInputWithBiometrics = forwardRef<
TextInput,
PasswordInputProps & { onPressBiometricUnlock: () => void }
>(function PasswordInputWithBiometrics(
{ onPressBiometricUnlock, hideBiometrics = false, ...passwordInputProps },
ref,
): JSX.Element {
const shouldShowBiometricUnlock = useShouldShowBiometricUnlock() && !hideBiometrics
return (
<Flex row width="100%" alignItems="center">
<Flex grow>
<PasswordInput
ref={ref}
{...passwordInputProps}
{...(shouldShowBiometricUnlock && {
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
})}
/>
</Flex>
{shouldShowBiometricUnlock && (
<TouchableArea
height="100%"
justifyContent="center"
px="$spacing12"
borderWidth="$spacing1"
borderLeftWidth={0}
borderTopLeftRadius={0}
borderBottomLeftRadius={0}
borderColor="$surface3"
backgroundColor="$surface1"
hoverStyle={{
backgroundColor: '$accent2',
}}
onPress={onPressBiometricUnlock}
>
<Fingerprint color="$accent1" size="$icon.28" />
</TouchableArea>
)}
</Flex>
)
})
function StrengthIndicator({ strength }: { strength: PasswordStrength }): JSX.Element | null {
const { t } = useTranslation()
if (strength === PasswordStrength.NONE) {
return null
}
const { text, color } = getPasswordStrengthTextAndColor(t, strength)
return (
<Flex position="absolute" right="$spacing24">
<Text color={color} variant="buttonLabel2">
{text}
</Text>
</Flex>
)
}
@@ -0,0 +1,117 @@
import { datadogRum } from '@datadog/browser-rum'
import { useQuery } from '@tanstack/react-query'
import { provideUniswapIdentifierService } from '@universe/api'
import { uniswapIdentifierQuery } from '@universe/sessions'
import { useEffect } from 'react'
import { useIsDarkMode } from 'ui/src'
import { DisplayNameType } from 'uniswap/src/features/accounts/types'
import { useEnabledChains } from 'uniswap/src/features/chains/hooks/useEnabledChains'
import { useAppFiatCurrencyInfo } from 'uniswap/src/features/fiatCurrency/hooks'
import { useCurrentLanguage } from 'uniswap/src/features/language/hooks'
import { useHideSmallBalancesSetting, useHideSpamTokensSetting } from 'uniswap/src/features/settings/hooks'
import { ExtensionUserPropertyName, setUserProperty } from 'uniswap/src/features/telemetry/user'
// oxlint-disable-next-line no-restricted-imports -- Direct analytics import required for user property tracking
import { analytics } from 'utilities/src/telemetry/analytics/analytics'
import { useGatingUserPropertyUsernames } from 'wallet/src/features/gating/userPropertyHooks'
import {
useActiveAccount,
useDisplayName,
useSignerAccounts,
useViewOnlyAccounts,
} from 'wallet/src/features/wallet/hooks'
/** Component that tracks UserProperties during the lifetime of the app */
export function TraceUserProperties(): null {
const isDarkMode = useIsDarkMode()
const viewOnlyAccounts = useViewOnlyAccounts()
const activeAccount = useActiveAccount()
const signerAccounts = useSignerAccounts()
const hideSmallBalances = useHideSmallBalancesSetting()
const hideSpamTokens = useHideSpamTokensSetting()
const currentLanguage = useCurrentLanguage()
const appFiatCurrencyInfo = useAppFiatCurrencyInfo()
const { isTestnetModeEnabled } = useEnabledChains()
const displayName = useDisplayName(activeAccount?.address)
const { data: uniswapIdentifier } = useQuery(uniswapIdentifierQuery(provideUniswapIdentifierService))
useGatingUserPropertyUsernames()
// Set user properties for datadog
useEffect(() => {
datadogRum.setUserProperty(ExtensionUserPropertyName.ActiveWalletAddress, activeAccount?.address)
}, [activeAccount?.address])
useEffect(() => {
if (uniswapIdentifier) {
datadogRum.setUserProperty(ExtensionUserPropertyName.UniswapIdentifier, uniswapIdentifier)
}
}, [uniswapIdentifier])
// Set user properties for amplitude
useEffect(() => {
setUserProperty(ExtensionUserPropertyName.AppVersion, chrome.runtime.getManifest().version)
return () => {
analytics.flushEvents()
}
}, [])
useEffect(() => {
setUserProperty(ExtensionUserPropertyName.DarkMode, isDarkMode)
}, [isDarkMode])
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
}
if (activeAccount.backups) {
setUserProperty(ExtensionUserPropertyName.BackupTypes, activeAccount.backups)
}
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])
useEffect(() => {
setUserProperty(ExtensionUserPropertyName.TestnetModeEnabled, isTestnetModeEnabled)
}, [isTestnetModeEnabled])
// Log ENS and Unitag ownership for user usage stats
useEffect(() => {
switch (displayName?.type) {
case DisplayNameType.ENS:
setUserProperty(ExtensionUserPropertyName.HasLoadedENS, true)
return
case DisplayNameType.Unitag:
setUserProperty(ExtensionUserPropertyName.HasLoadedUnitag, true)
return
default:
return
}
}, [displayName?.type])
return null
}
@@ -0,0 +1,18 @@
import { datadogRum } from '@datadog/browser-rum'
import { useEffect } from 'react'
import { useDappContext } from 'src/app/features/dapp/DappContext'
/** Hook that tracks dapp URL for Datadog logs in the Sidebar */
export function useTraceSidebarDappUrl(): void {
const dappContext = useDappContext()
useEffect(() => {
// Set dapp URL as global context property so it's included in all logs
if (dappContext.dappUrl) {
datadogRum.setGlobalContextProperty('dappUrl', dappContext.dappUrl)
} else {
// Remove the property when no dapp is active
datadogRum.removeGlobalContextProperty('dappUrl')
}
}, [dappContext.dappUrl])
}
@@ -0,0 +1,75 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { AnimatePresence, Flex, Text, TouchableArea } from 'ui/src'
import { Check, CopySheets } from 'ui/src/components/icons'
import { iconSizes, zIndexes } 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={zIndexes.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="$icon.12" />
)}
<Text
color={valueCopied ? '$statusSuccess' : '$neutral2'}
cursor="pointer"
flexShrink={1}
variant="buttonLabel3"
x={valueCopied ? -2 : 0}
y={0.5}
>
{valueCopied ? t('common.button.copied') : t('common.button.copy')}
</Text>
</Flex>
</AnimatePresence>
</Flex>
</TouchableArea>
</Flex>
)
}
@@ -0,0 +1,29 @@
import { useTranslation } from 'react-i18next'
import { Button, Flex } from 'ui/src'
import { ArrowRight } from 'ui/src/components/icons/ArrowRight'
export function OpenSidebarButton({
openedSideBar,
handleOpenSidebar,
handleOpenWebApp,
}: {
openedSideBar: boolean
handleOpenSidebar: () => Promise<void>
handleOpenWebApp: () => Promise<void>
}) {
const { t } = useTranslation()
return (
<Flex row alignSelf="stretch">
<Button
icon={openedSideBar ? <ArrowRight /> : undefined}
iconPosition="after"
size="large"
variant={openedSideBar ? 'branded' : 'default'}
emphasis={openedSideBar ? 'primary' : 'secondary'}
onPress={openedSideBar ? handleOpenWebApp : handleOpenSidebar}
>
{openedSideBar ? t('onboarding.complete.go_to_uniswap') : t('onboarding.complete.button')}
</Button>
</Flex>
)
}
@@ -0,0 +1,46 @@
import { Circle, Flex, GeneratedIcon, Text, TouchableArea } from 'ui/src'
import { iconSizes } from 'ui/src/theme'
export function OptionCard({
Icon,
title,
subtitle,
onPress,
}: {
Icon: GeneratedIcon
title: string
subtitle: string
onPress: () => void
}): JSX.Element {
return (
<TouchableArea
width="100%"
shadowColor="$shadowColor"
shadowOpacity={0.05}
shadowRadius={8}
borderWidth="$spacing1"
borderColor="$surface3"
borderRadius="$rounded20"
onPress={onPress}
>
<Flex row fill gap="$spacing12" p="$spacing12" width="100%">
<Circle
backgroundColor="$accent2"
borderRadius="$roundedFull"
height={iconSizes.icon32}
width={iconSizes.icon32}
>
<Icon color="$accent1" size="$icon.16" />
</Circle>
<Flex fill gap="$spacing4">
<Text variant="body2">{title}</Text>
<Text color="$neutral2" variant="body3">
{subtitle}
</Text>
</Flex>
</Flex>
</TouchableArea>
)
}
@@ -0,0 +1,34 @@
import { useExtensionNavigation } from 'src/app/navigation/utils'
import { Flex, GeneratedIcon, IconProps, Text, TouchableArea } from 'ui/src'
import { BackArrow } from 'ui/src/components/icons'
import { iconSizes } from 'ui/src/theme'
export function ScreenHeader({
onBackClick,
title,
rightColumn,
Icon = BackArrow,
}: {
title?: JSX.Element | string
onBackClick?: () => void
rightColumn?: JSX.Element
Icon?: GeneratedIcon | ((props: IconProps) => JSX.Element)
}): JSX.Element {
const { navigateBack } = useExtensionNavigation()
return (
<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>
)
}
@@ -0,0 +1,44 @@
import { SkeletonBox } from 'src/app/components/loading/SkeletonBox'
import { Flex } from 'ui/src'
import { WALLET_PREVIEW_CARD_MIN_HEIGHT } from 'wallet/src/components/WalletPreviewCard/WalletPreviewCard'
export function SelectWalletsSkeleton({ repeat = 3 }: { repeat?: number }): JSX.Element {
return (
<Flex fill gap="$spacing12">
{/* oxlint-disable-next-line max-params */}
{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="$spacing1"
height={WALLET_PREVIEW_CARD_MIN_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>
)
}
@@ -0,0 +1,40 @@
.skeleton-box {
display: inline-block;
height: 1em;
position: relative;
overflow: hidden;
}
.skeleton-box::after {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
transform: translateX(-100%);
background-image: linear-gradient(
-75deg,
rgba(240, 240, 240, 0) 0,
rgba(240, 240, 240, 0.2) 20%,
rgba(240, 240, 240, 0.5) 60%,
rgba(240, 240, 240, 0)
);
animation: skeleton-box-shimmer 1s linear infinite;
content: '';
}
.t_dark .skeleton-box::after {
background-image: linear-gradient(
-75deg,
rgba(30, 30, 30, 0) 0,
rgba(30, 30, 30, 0.2) 20%,
rgba(30, 30, 30, 0.5) 60%,
rgba(30, 30, 30, 0)
);
}
@keyframes skeleton-box-shimmer {
100% {
transform: translateX(100%);
}
}
@@ -0,0 +1,17 @@
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 {
// oxlint-disable-next-line react/forbid-elements -- needed here
return <div className="skeleton-box" style={{ width, height, borderRadius }} />
}
@@ -0,0 +1,78 @@
import { ReactNode } from 'react'
import { Anchor, Button, ButtonEmphasis, Flex, Text, TouchableArea, useSporeColors } from 'ui/src'
import { X } from 'ui/src/components/icons'
import { zIndexes } from 'ui/src/theme'
import { Modal } from 'uniswap/src/components/modals/Modal'
import { ModalNameType } from 'uniswap/src/features/telemetry/constants'
export interface ModalProps {
name: ModalNameType
isOpen: boolean
showCloseButton?: boolean
onDismiss?: () => void
icon: ReactNode
title: string
description: string
buttonText: string
buttonEmphasis?: ButtonEmphasis
onButtonPress?: () => void
linkText?: string
linkUrl?: string
}
export function InfoModal({
name,
isOpen,
showCloseButton,
onDismiss,
icon,
title,
description,
buttonText,
buttonEmphasis,
onButtonPress,
linkText,
linkUrl,
}: React.PropsWithChildren<ModalProps>): JSX.Element {
const colors = useSporeColors()
return (
<Modal backgroundColor={colors.surface1.val} isModalOpen={isOpen} name={name} onClose={onDismiss}>
{showCloseButton && (
<TouchableArea
p="$spacing16"
position="absolute"
right={0}
top={0}
zIndex={zIndexes.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>
<Flex row alignSelf="stretch">
<Button size="medium" emphasis={buttonEmphasis} onPress={onButtonPress}>
{buttonText}
</Button>
</Flex>
{linkText && linkUrl && (
<Anchor href={linkUrl} lineHeight={16} p="$spacing12" target="_blank" textDecorationLine="none">
<Text color="$neutral2" textAlign="center" variant="buttonLabel3">
{linkText}
</Text>
</Anchor>
)}
</Flex>
</Modal>
)
}
@@ -0,0 +1,43 @@
import { useDispatch } from 'react-redux'
import { useSmartWalletNudges } from 'src/app/context/SmartWalletNudgesContext'
import { ModalName } from 'uniswap/src/features/telemetry/constants'
import { SmartWalletCreatedModal } from 'wallet/src/components/smartWallet/modals/SmartWalletCreatedModal'
import { SmartWalletEnabledModal } from 'wallet/src/components/smartWallet/modals/SmartWalletEnabledModal'
import { SmartWalletNudge } from 'wallet/src/components/smartWallet/modals/SmartWalletNudge'
import { useActiveAccount } from 'wallet/src/features/wallet/hooks'
import { setSmartWalletConsent } from 'wallet/src/features/wallet/slice'
export function SmartWalletNudgeModals(): JSX.Element | null {
const dispatch = useDispatch()
const address = useActiveAccount()?.address
const { activeModal, closeModal, openModal, dappInfo } = useSmartWalletNudges()
if (!activeModal) {
return null
}
switch (activeModal) {
case ModalName.SmartWalletCreatedModal:
return <SmartWalletCreatedModal isOpen onClose={closeModal} />
case ModalName.SmartWalletNudge:
return (
<SmartWalletNudge
isOpen
onClose={closeModal}
dappInfo={dappInfo}
onEnableSmartWallet={() => {
if (!address) {
return
}
dispatch(setSmartWalletConsent({ address, smartWalletConsent: true }))
openModal(ModalName.SmartWalletEnabledModal)
}}
/>
)
case ModalName.SmartWalletEnabledModal:
return <SmartWalletEnabledModal isOpen showReconnectDappPrompt={!!dappInfo} onClose={closeModal} />
default:
return null
}
}
@@ -0,0 +1,43 @@
import { memo } from 'react'
import { Flex, Loader, ScrollView } from 'ui/src'
import { useInfiniteScroll } from 'utilities/src/react/useInfiniteScroll'
import { useActivityDataWallet } from 'wallet/src/features/activity/useActivityDataWallet'
export const ActivityTab = memo(function ActivityTabInner({
address,
skip,
}: {
address: Address
skip?: boolean
}): JSX.Element {
const { maybeEmptyComponent, renderActivityItem, sectionData, fetchNextPage, hasNextPage, isFetchingNextPage } =
useActivityDataWallet({
evmOwner: address,
skip,
})
const { sentinelRef } = useInfiniteScroll({
onLoadMore: fetchNextPage,
hasNextPage,
isFetching: isFetchingNextPage,
})
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 }))}
{/* Show skeleton loading indicator while fetching next page */}
{isFetchingNextPage && (
<Flex px="$spacing8">
<Loader.Transaction />
</Flex>
)}
{/* Intersection observer sentinel for infinite scroll */}
<Flex ref={sentinelRef} height={1} my={10} />
</ScrollView>
)
})
@@ -0,0 +1,48 @@
import { SharedEventName } from '@uniswap/analytics-events'
import { memo, useCallback } from 'react'
import { Flex } from 'ui/src'
// This is intentionally imported from the native file as only the web app requires a web specific implementation
import { NftsList } from 'uniswap/src/components/nfts/NftsList.native'
import { NftViewWithContextMenu } from 'uniswap/src/components/nfts/NftViewWithContextMenu'
import { NFTItem } from 'uniswap/src/features/nfts/types'
import { ElementName, SectionName } from 'uniswap/src/features/telemetry/constants'
import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send'
import { useAccounts } from 'wallet/src/features/wallet/hooks'
export const NftsTab = memo(function NftsTabInner({ owner, skip }: { owner: Address; skip?: boolean }): JSX.Element {
const accounts = useAccounts()
const renderNFTItem = useCallback(
(item: NFTItem) => {
const onPress = (): void => {
sendAnalyticsEvent(SharedEventName.ELEMENT_CLICKED, {
element: ElementName.NftItem,
section: SectionName.HomeNFTsTab,
})
}
return (
<Flex fill m="$spacing4">
<NftViewWithContextMenu walletAddresses={Object.keys(accounts)} item={item} owner={owner} onPress={onPress} />
</Flex>
)
},
[accounts, owner],
)
return (
<NftsList
emptyStateStyle={defaultEmptyStyle}
errorStateStyle={defaultEmptyStyle}
owner={owner}
renderNFTItem={renderNFTItem}
skip={skip}
/>
)
})
const defaultEmptyStyle = {
minHeight: 100,
paddingVertical: '$spacing12',
width: '100%',
}
+3
View File
@@ -0,0 +1,3 @@
import { SpaceTokens } from 'ui/src'
export const SCREEN_ITEM_HORIZONTAL_PAD = '$spacing12' satisfies SpaceTokens
@@ -0,0 +1,128 @@
import { createContext, ReactNode, useCallback, useContext, useEffect, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { useGet5792DappInfo } from 'src/app/hooks/useGet5792DappInfo'
import { ModalName, ModalNameType } from 'uniswap/src/features/telemetry/constants'
import { extractUrlHost } from 'utilities/src/format/urls'
import { useEvent } from 'utilities/src/react/hooks'
import { ONE_DAY_MS } from 'utilities/src/time/time'
import {
SmartWalletDelegationAction,
useSmartWalletDelegationStatus,
} from 'wallet/src/components/smartWallet/smartAccounts/hooks'
import {
selectHasSeenCreatedSmartWalletModal,
selectHasShownEip5792Nudge,
} from 'wallet/src/features/behaviorHistory/selectors'
import {
setHasSeenSmartWalletCreatedWalletModal,
setHasShown5792Nudge,
} from 'wallet/src/features/behaviorHistory/slice'
import { useAccountCountChanged } from 'wallet/src/features/wallet/hooks'
import { WalletState } from 'wallet/src/state/walletReducer'
type DappInfo = {
icon?: string
name?: string
}
type SmartWalletNudgesContextState = {
activeModal: ModalNameType | null
openModal: (modal: ModalNameType) => void
closeModal: () => void
dappInfo?: DappInfo
setDappInfo: (info?: DappInfo) => void
}
const SmartWalletNudgesContext = createContext<SmartWalletNudgesContextState | undefined>(undefined)
export function SmartWalletNudgesProvider({ children }: { children: ReactNode }): JSX.Element {
const dispatch = useDispatch()
const [activeModal, setActiveModal] = useState<ModalNameType | null>(null)
const [dappInfo, setDappInfo] = useState<{
icon?: string
name?: string
}>()
const openModal = useCallback((modal: ModalNameType): void => {
setActiveModal(modal)
}, [])
const closeModal = useCallback((): void => {
setActiveModal(null)
}, [])
const last5792DappInfo = useGet5792DappInfo()
const delegationStatus = useSmartWalletDelegationStatus({ overrideAddress: last5792DappInfo?.activeConnectedAddress })
const hasShownNudge = useSelector((state: WalletState) =>
last5792DappInfo
? selectHasShownEip5792Nudge(state, last5792DappInfo.activeConnectedAddress, last5792DappInfo.url)
: false,
)
// Check if home screen nudge was recently shown (24 hour cooldown)
const lastHomeScreenShown = useSelector((state: WalletState) =>
last5792DappInfo
? state.behaviorHistory.smartWalletNudge?.[last5792DappInfo.activeConnectedAddress]?.lastHomeScreenNudgeShown
: undefined,
)
const hasRecentHomeScreenShown = lastHomeScreenShown ? Date.now() - lastHomeScreenShown < ONE_DAY_MS : false
const shouldShowNudge =
!hasShownNudge &&
!hasRecentHomeScreenShown &&
delegationStatus.status === SmartWalletDelegationAction.PromptUpgrade &&
!delegationStatus.loading
// oxlint-disable-next-line react/exhaustive-deps -- delegationStatus is used in shouldShowNudge calculation above
useEffect(() => {
if (last5792DappInfo && shouldShowNudge) {
setDappInfo({
icon: last5792DappInfo.iconUrl,
name: last5792DappInfo.displayName || extractUrlHost(last5792DappInfo.url),
})
openModal(ModalName.SmartWalletNudge)
dispatch(
setHasShown5792Nudge({
walletAddress: last5792DappInfo.activeConnectedAddress,
dappUrl: last5792DappInfo.url,
}),
)
}
}, [dispatch, last5792DappInfo, delegationStatus, openModal, shouldShowNudge])
const hasSeenCreatedSmartWalletModal = useSelector(selectHasSeenCreatedSmartWalletModal)
// Show SmartWalletEnabledModal when account count increases
useAccountCountChanged(
useEvent(() => {
if (hasSeenCreatedSmartWalletModal) {
return
}
setDappInfo(undefined)
openModal(ModalName.SmartWalletCreatedModal)
dispatch(setHasSeenSmartWalletCreatedWalletModal())
}),
)
return (
<SmartWalletNudgesContext.Provider
value={{
activeModal,
openModal,
closeModal,
dappInfo,
setDappInfo,
}}
>
{children}
</SmartWalletNudgesContext.Provider>
)
}
export function useSmartWalletNudges(): SmartWalletNudgesContextState {
const context = useContext(SmartWalletNudgesContext)
if (!context) {
throw new Error('useSmartWalletNudges must be used within a SmartWalletNudgesProvider')
}
return context
}
@@ -0,0 +1,150 @@
import { ApiInit, getEntryGatewayUrl, provideSessionService } from '@universe/api'
import {
getIsHashcashSolverEnabled,
getIsSessionServiceEnabled,
getIsSessionsPerformanceTrackingEnabled,
getIsSessionUpgradeAutoEnabled,
getIsTurnstileSolverEnabled,
useIsSessionServiceEnabled,
} from '@universe/gating'
import {
type ChallengeSolver,
ChallengeType,
createChallengeSolverService,
createHashcashMockSolver,
createHashcashSolver,
createHashcashWorkerChannel,
createPerformanceTracker,
createSessionInitializationService,
createTurnstileMockSolver,
type SessionInitializationService,
} from '@universe/sessions'
import { PropsWithChildren, useEffect } from 'react'
import { I18nextProvider } from 'react-i18next'
import { GraphqlProvider } from 'src/app/apollo'
import { TraceUserProperties } from 'src/app/components/Trace/TraceUserProperties'
import { ExtensionStatsigProvider } from 'src/app/core/StatsigProvider'
import { type DatadogAppNameTag } from 'src/app/datadog'
import { onHashcashSolveCompleted, sessionInitAnalytics } from 'src/app/features/sessions/analytics'
import { useOnCrashAppStateResetter } from 'src/store/appStateResetter'
import { getReduxStore } from 'src/store/store'
import { BlankUrlProvider } from 'uniswap/src/contexts/UrlContext'
import { useCurrentLanguage } from 'uniswap/src/features/language/hooks'
import { LocalizationContextProvider } from 'uniswap/src/features/language/LocalizationContext'
import { getLocale } from 'uniswap/src/features/language/navigatorLocale'
import Trace from 'uniswap/src/features/telemetry/Trace'
import i18n, { changeLanguage } from 'uniswap/src/i18n'
import { getLogger } from 'utilities/src/logger/logger'
import { ErrorBoundary } from 'wallet/src/components/ErrorBoundary/ErrorBoundary'
import { StatsigUserIdentifiersUpdater } from 'wallet/src/features/gating/StatsigUserIdentifiersUpdater'
import { SharedWalletProvider } from 'wallet/src/providers/SharedWalletProvider'
const provideSessionInitializationService = (): SessionInitializationService => {
// Create performance tracker with feature flag control
const performanceTracker = createPerformanceTracker({
getIsPerformanceTrackingEnabled: getIsSessionsPerformanceTrackingEnabled,
getNow: () => performance.now(),
})
const solvers = new Map<ChallengeType, ChallengeSolver>()
if (getIsTurnstileSolverEnabled()) {
solvers.set(ChallengeType.TURNSTILE, createTurnstileMockSolver())
} else {
solvers.set(ChallengeType.TURNSTILE, createTurnstileMockSolver())
}
if (getIsHashcashSolverEnabled()) {
solvers.set(
ChallengeType.HASHCASH,
createHashcashSolver({
performanceTracker,
getWorkerChannel: () =>
createHashcashWorkerChannel({
getWorker: () =>
new Worker(
new URL('@universe/sessions/src/challenge-solvers/hashcash/worker/hashcash.worker.ts', import.meta.url),
{ type: 'module' },
),
}),
onSolveCompleted: onHashcashSolveCompleted,
getLogger,
}),
)
} else {
solvers.set(ChallengeType.HASHCASH, createHashcashMockSolver())
}
return createSessionInitializationService({
getSessionService: () =>
provideSessionService({
getBaseUrl: getEntryGatewayUrl,
getIsSessionServiceEnabled,
}),
challengeSolverService: createChallengeSolverService({
solvers,
}),
performanceTracker,
getIsSessionUpgradeAutoEnabled,
getLogger,
analytics: sessionInitAnalytics,
})
}
/**
* Inner component that uses hooks requiring Redux context.
*/
function ErrorBoundaryWrapper({ children }: PropsWithChildren): JSX.Element {
const onCrashAppStateResetter = useOnCrashAppStateResetter()
return <ErrorBoundary appStateResetter={onCrashAppStateResetter}>{children}</ErrorBoundary>
}
function BaseAppContainerInner({ children }: PropsWithChildren): JSX.Element {
const isSessionServiceEnabled = useIsSessionServiceEnabled()
return (
<I18nextProvider i18n={i18n}>
<SharedWalletProvider reduxStore={getReduxStore()}>
<ErrorBoundaryWrapper>
<LanguageSync />
<GraphqlProvider>
<BlankUrlProvider>
<LocalizationContextProvider>
<TraceUserProperties />
<StatsigUserIdentifiersUpdater />
<ApiInit
getSessionInitService={provideSessionInitializationService}
isSessionServiceEnabled={isSessionServiceEnabled}
/>
{children}
</LocalizationContextProvider>
</BlankUrlProvider>
</GraphqlProvider>
</ErrorBoundaryWrapper>
</SharedWalletProvider>
</I18nextProvider>
)
}
export function BaseAppContainer({
children,
appName,
}: PropsWithChildren<{ appName: DatadogAppNameTag }>): JSX.Element {
return (
<Trace>
<ExtensionStatsigProvider appName={appName}>
<BaseAppContainerInner>{children}</BaseAppContainerInner>
</ExtensionStatsigProvider>
</Trace>
)
}
function LanguageSync(): null {
const currentLanguage = useCurrentLanguage()
useEffect(() => {
changeLanguage(getLocale(currentLanguage)).catch(() => undefined)
}, [currentLanguage])
return null
}
@@ -0,0 +1,47 @@
import { lazy, Suspense } from 'react'
import { Flex } from 'ui/src'
import { Flag } from 'ui/src/components/icons/Flag'
import { Modal } from 'uniswap/src/components/modals/Modal'
import { ModalName } from 'uniswap/src/features/telemetry/constants'
import { useBooleanState } from 'utilities/src/react/useBooleanState'
const DevMenuScreen = lazy(() =>
import('src/app/features/settings/DevMenuScreen').then((module) => ({ default: module.DevMenuScreen })),
)
export function DevMenuModal(): JSX.Element {
const { value: isOpen, setTrue: openModal, setFalse: closeModal } = useBooleanState(false)
return (
<>
<Flex
$platform-web={{
position: 'fixed',
}}
bottom="$spacing24"
p="$spacing4"
left="$spacing24"
zIndex={Number.MAX_SAFE_INTEGER}
borderWidth="$spacing1"
borderColor="$neutral2"
borderRadius="$rounded4"
cursor="pointer"
backgroundColor="$surface1"
hoverStyle={{
backgroundColor: '$surface1Hovered',
}}
onPress={openModal}
>
<Flag size="$icon.24" color="$neutral2" />
</Flex>
{isOpen && (
<Modal name={ModalName.FeatureFlags} onClose={closeModal}>
<Suspense>
<DevMenuScreen />
</Suspense>
</Modal>
)}
</>
)
}
@@ -0,0 +1,15 @@
import { render } from '@testing-library/react'
import OnboardingApp from 'src/app/core/OnboardingApp'
import { initializeReduxStore } from 'src/store/store'
jest.mock('wallet/src/features/transactions/contexts/WalletUniswapContext', () => ({
WalletUniswapProvider: ({ children }: { children: React.ReactNode }) => children,
}))
describe('OnboardingApp', () => {
// oxlint-disable-next-line jest/expect-expect
it('renders without error', async () => {
initializeReduxStore()
render(<OnboardingApp />)
})
})
@@ -0,0 +1,208 @@
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 { createHashRouter, RouteObject, RouterProvider } from 'react-router'
import { PersistGate } from 'redux-persist/integration/react'
import { ErrorElement } from 'src/app/components/ErrorElement'
import { BaseAppContainer } from 'src/app/core/BaseAppContainer'
import { DatadogAppNameTag } from 'src/app/datadog'
import { ClaimUnitagScreen } from 'src/app/features/onboarding/ClaimUnitagScreen'
import { Complete } from 'src/app/features/onboarding/Complete'
import { PasswordCreate } from 'src/app/features/onboarding/create/PasswordCreate'
import { ImportMnemonic } from 'src/app/features/onboarding/import/ImportMnemonic'
import { InitiatePasskeyAuth } from 'src/app/features/onboarding/import/InitiatePasskeyAuth'
import { PasskeyImport } from 'src/app/features/onboarding/import/PasskeyImport'
import { PasskeyImportContextProvider } from 'src/app/features/onboarding/import/PasskeyImportContextProvider'
import { SelectImportMethod } from 'src/app/features/onboarding/import/SelectImportMethod'
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 {
CreateOnboardingSteps,
ImportOnboardingSteps,
ImportPasskeySteps,
OnboardingStepsProvider,
ResetSteps,
ScanOnboardingSteps,
SelectImportMethodSteps,
} from 'src/app/features/onboarding/OnboardingSteps'
import { OnboardingWrapper } from 'src/app/features/onboarding/OnboardingWrapper'
import { PasswordImport } from 'src/app/features/onboarding/PasswordImport'
import { ResetComplete } from 'src/app/features/onboarding/reset/ResetComplete'
import { OTPInput } from 'src/app/features/onboarding/scan/OTPInput'
import { ScantasticContextProvider } from 'src/app/features/onboarding/scan/ScantasticContextProvider'
import { ScanToOnboard } from 'src/app/features/onboarding/scan/ScanToOnboard'
import { OnboardingRoutes, TopLevelRoutes } from 'src/app/navigation/constants'
import { OnboardingNavigationProvider } from 'src/app/navigation/providers'
import { setRouter, setRouterState } from 'src/app/navigation/state'
import { initExtensionAnalytics } from 'src/app/utils/analytics'
import { checksIfSupportsSidePanel } from 'src/app/utils/chrome'
import { PrimaryAppInstanceDebuggerLazy } from 'src/store/PrimaryAppInstanceDebuggerLazy'
import { ExtensionEventName } from 'uniswap/src/features/telemetry/constants'
import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send'
import { ExtensionOnboardingFlow } from 'uniswap/src/types/screens/extension'
import { AccountsStoreContextProvider } from 'wallet/src/features/accounts/store/provider'
import { WalletUniswapProvider } from 'wallet/src/features/transactions/contexts/WalletUniswapContext'
import { getReduxPersistor } from 'wallet/src/state/persistor'
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.ClaimUnitag]: <ClaimUnitagScreen />,
[CreateOnboardingSteps.Password]: <PasswordCreate />,
[CreateOnboardingSteps.Complete]: <Complete tryToClaimUnitag flow={ExtensionOnboardingFlow.New} />,
}}
/>
),
},
{
path: OnboardingRoutes.SelectImportMethod,
element: (
<OnboardingStepsProvider
key={OnboardingRoutes.SelectImportMethod}
steps={{
[SelectImportMethodSteps.SelectMethod]: <SelectImportMethod />,
}}
/>
),
},
{
path: OnboardingRoutes.ImportPasskey,
element: (
<OnboardingStepsProvider
ContainerComponent={PasskeyImportContextProvider}
key={OnboardingRoutes.ImportPasskey}
steps={{
[ImportPasskeySteps.InitiatePasskeyAuth]: <InitiatePasskeyAuth />,
[ImportPasskeySteps.PasskeyImport]: <PasskeyImport />,
[ImportOnboardingSteps.Password]: <PasswordImport flow={ExtensionOnboardingFlow.Passkey} />,
[ImportOnboardingSteps.Select]: <SelectWallets flow={ExtensionOnboardingFlow.Passkey} />,
[ImportOnboardingSteps.Complete]: <Complete flow={ExtensionOnboardingFlow.Passkey} />,
}}
/>
),
},
{
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 = createHashRouter([
{
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 (
<PersistGate persistor={getReduxPersistor()}>
<BaseAppContainer appName={DatadogAppNameTag.Onboarding}>
<OnboardingNavigationProvider>
<WalletUniswapProvider>
<AccountsStoreContextProvider>
<PrimaryAppInstanceDebuggerLazy />
<RouterProvider router={router} />
</AccountsStoreContextProvider>
</WalletUniswapProvider>
</OnboardingNavigationProvider>
</BaseAppContainer>
</PersistGate>
)
}
+107
View File
@@ -0,0 +1,107 @@
import '@tamagui/core/reset.css'
import 'src/app/Global.css'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { createHashRouter, RouterProvider } from 'react-router'
import { ErrorElement } from 'src/app/components/ErrorElement'
import { BaseAppContainer } from 'src/app/core/BaseAppContainer'
import { DatadogAppNameTag } from 'src/app/datadog'
import { initExtensionAnalytics } from 'src/app/utils/analytics'
import { Button, Flex, Image, Text } from 'ui/src'
import { UNISWAP_LOGO } from 'ui/src/assets'
import { GoogleChromeLogo } from 'ui/src/components/logos/GoogleChromeLogo'
import { iconSizes, spacing } from 'ui/src/theme'
import { ElementName } from 'uniswap/src/features/telemetry/constants'
import Trace from 'uniswap/src/features/telemetry/Trace'
import { ExtensionScreens } from 'uniswap/src/types/screens/extension'
import { useTestnetModeForLoggingAndAnalytics } from 'wallet/src/features/testnetMode/hooks/useTestnetModeForLoggingAndAnalytics'
const router = createHashRouter([
{
path: '',
element: <PopupContent />,
errorElement: <ErrorElement />,
},
])
function PopupContent(): JSX.Element {
const { t } = useTranslation()
useTestnetModeForLoggingAndAnalytics()
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="$spacing1"
bottom={-spacing.spacing4}
p="$spacing2"
position="absolute"
right={-spacing.spacing4}
>
<GoogleChromeLogo size={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}>
<Flex row>
<Button
variant="branded"
emphasis="primary"
size="medium"
width="100%"
onPress={async () => {
if (windowIdNumber) {
await chrome.sidePanel.open({ tabId: tabIdNumber, windowId: windowIdNumber })
window.close()
}
}}
>
{t('extension.popup.chrome.button')}
</Button>
</Flex>
</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 (
<BaseAppContainer appName={DatadogAppNameTag.Popup}>
<RouterProvider router={router} />
</BaseAppContainer>
)
}
+291
View File
@@ -0,0 +1,291 @@
import '@tamagui/core/reset.css'
import 'src/app/Global.css'
import { SharedEventName } from '@uniswap/analytics-events'
import { useEffect, useRef, useState } from 'react'
import { useDispatch } from 'react-redux'
import { createHashRouter, RouterProvider } from 'react-router'
import { PersistGate } from 'redux-persist/integration/react'
import { AUTO_LOCK_ALARM_NAME } from 'src/app/components/AutoLockProvider'
import { ErrorElement } from 'src/app/components/ErrorElement'
import { useTraceSidebarDappUrl } from 'src/app/components/Trace/useTraceSidebarDappUrl'
import { BaseAppContainer } from 'src/app/core/BaseAppContainer'
import { DatadogAppNameTag } from 'src/app/datadog'
import { AccountSwitcherScreen } from 'src/app/features/accounts/AccountSwitcherScreen'
import { DappContextProvider } from 'src/app/features/dapp/DappContext'
import { addRequest } from 'src/app/features/dappRequests/actions'
import { ReceiveScreen } from 'src/app/features/receive/ReceiveScreen'
import { SendFlow } from 'src/app/features/send/SendFlow'
import { BackupRecoveryPhraseScreen } from 'src/app/features/settings/BackupRecoveryPhrase/BackupRecoveryPhraseScreen'
import { DeviceAccessScreen } from 'src/app/features/settings/DeviceAccessScreen'
import { DevMenuScreen } from 'src/app/features/settings/DevMenuScreen'
import { HashcashBenchmarkScreen } from 'src/app/features/settings/HashcashBenchmarkScreen'
import { SessionsDebugScreen } from 'src/app/features/settings/SessionsDebugScreen'
import { SettingsManageConnectionsScreen } from 'src/app/features/settings/SettingsManageConnectionsScreen/SettingsManageConnectionsScreen'
import { RemoveRecoveryPhraseVerify } from 'src/app/features/settings/SettingsRecoveryPhraseScreen/RemoveRecoveryPhraseVerify'
import { RemoveRecoveryPhraseWallets } from 'src/app/features/settings/SettingsRecoveryPhraseScreen/RemoveRecoveryPhraseWallets'
import { ViewRecoveryPhraseScreen } from 'src/app/features/settings/SettingsRecoveryPhraseScreen/ViewRecoveryPhraseScreen'
import { SettingsScreen } from 'src/app/features/settings/SettingsScreen'
import { SettingsScreenWrapper } from 'src/app/features/settings/SettingsScreenWrapper'
import { SettingsStorageScreen } from 'src/app/features/settings/SettingsStorageScreen'
import { SmartWalletSettingsScreen } from 'src/app/features/settings/SmartWalletSettingsScreen'
import { SwapFlowScreen } from 'src/app/features/swap/SwapFlowScreen'
import { useIsWalletUnlocked } from 'src/app/hooks/useIsWalletUnlocked'
import { AppRoutes, RemoveRecoveryPhraseRoutes, SettingsRoutes } from 'src/app/navigation/constants'
import { MainContent, WebNavigation } from 'src/app/navigation/navigation'
import { setRouter, setRouterState } from 'src/app/navigation/state'
import { initExtensionAnalytics } from 'src/app/utils/analytics'
import {
backgroundToSidePanelMessageChannel,
createBackgroundToSidePanelMessagePort,
DappBackgroundPortChannel,
} from 'src/background/messagePassing/messageChannels'
import { BackgroundToSidePanelRequestType } from 'src/background/messagePassing/types/requests'
import { PrimaryAppInstanceDebuggerLazy } from 'src/store/PrimaryAppInstanceDebuggerLazy'
import { useResetUnitagsQueries } from 'uniswap/src/data/apiClients/unitagsApi/useResetUnitagsQueries'
import { ExtensionEventName } from 'uniswap/src/features/telemetry/constants'
import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send'
import { isDevEnv } from 'utilities/src/environment/env'
import { logger } from 'utilities/src/logger/logger'
import { ONE_SECOND_MS } from 'utilities/src/time/time'
import { useInterval } from 'utilities/src/time/timing'
import { useTestnetModeForLoggingAndAnalytics } from 'wallet/src/features/testnetMode/hooks/useTestnetModeForLoggingAndAnalytics'
import { getReduxPersistor } from 'wallet/src/state/persistor'
const router = createHashRouter([
{
path: '',
element: <SidebarWrapper />,
errorElement: <ErrorElement />,
children: [
{
path: '',
element: <MainContent />,
},
{
path: AppRoutes.AccountSwitcher,
element: <AccountSwitcherScreen />,
},
{
path: AppRoutes.Settings,
element: <SettingsScreenWrapper />,
children: [
{
path: '',
element: <SettingsScreen />,
},
{
path: SettingsRoutes.DeviceAccess,
element: <DeviceAccessScreen />,
},
...(isDevEnv()
? [
{
path: SettingsRoutes.DevMenu,
element: <DevMenuScreen />,
},
{
path: SettingsRoutes.SessionsDebug,
element: <SessionsDebugScreen />,
},
{
path: SettingsRoutes.HashcashBenchmark,
element: <HashcashBenchmarkScreen />,
},
]
: []),
{
path: SettingsRoutes.ViewRecoveryPhrase,
element: <ViewRecoveryPhraseScreen />,
},
{
path: SettingsRoutes.BackupRecoveryPhrase,
element: <BackupRecoveryPhraseScreen />,
},
{
path: SettingsRoutes.RemoveRecoveryPhrase,
children: [
{
path: RemoveRecoveryPhraseRoutes.Wallets,
element: <RemoveRecoveryPhraseWallets />,
},
{
path: RemoveRecoveryPhraseRoutes.Verify,
element: <RemoveRecoveryPhraseVerify />,
},
],
},
{
path: SettingsRoutes.ManageConnections,
element: <SettingsManageConnectionsScreen />,
},
{
path: SettingsRoutes.SmartWallet,
element: <SmartWalletSettingsScreen />,
},
{
path: SettingsRoutes.Storage,
element: <SettingsStorageScreen />,
},
],
},
{
path: AppRoutes.Send,
element: <SendFlow />,
},
{
path: AppRoutes.Swap,
element: <SwapFlowScreen />,
},
{
path: AppRoutes.Receive,
element: <ReceiveScreen />,
},
],
},
])
const PORT_PING_INTERVAL = 5 * ONE_SECOND_MS
function useDappRequestPortListener(): void {
const dispatch = useDispatch()
const [currentPortChannel, setCurrentPortChannel] = useState<DappBackgroundPortChannel | undefined>()
const [windowId, setWindowId] = useState<string | undefined>()
// oxlint-disable-next-line react/exhaustive-deps -- Only run on component mount for initial setup, disconnect cleanup is managed separately
useEffect(() => {
chrome.windows.getCurrent((window) => {
setWindowId(window.id?.toString())
})
return () => currentPortChannel?.port.disconnect()
// oxlint-disable-next-line react/exhaustive-deps -- biome-parity: oxlint is stricter here
}, [])
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)
}
/**
* Creates a connection so that the background script can detect when the sidebar is closed and schedule an auto-lock alarm.
*/
function useAutoLockAlarmConnection(): void {
useEffect(() => {
const port = chrome.runtime.connect({ name: AUTO_LOCK_ALARM_NAME })
return () => {
port.disconnect()
}
}, [])
}
function SidebarWrapper(): JSX.Element {
useDappRequestPortListener()
useAutoLockAlarmConnection()
useTestnetModeForLoggingAndAnalytics()
const resetUnitagsQueries = useResetUnitagsQueries()
useEffect(() => {
return backgroundToSidePanelMessageChannel.addMessageListener(
BackgroundToSidePanelRequestType.RefreshUnitags,
() => {
resetUnitagsQueries()
},
)
}, [resetUnitagsQueries])
return (
<>
<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)
function SidebarContent(): JSX.Element {
useTraceSidebarDappUrl()
return (
<>
<PrimaryAppInstanceDebuggerLazy />
<RouterProvider router={router} />
</>
)
}
export default function SidebarApp(): JSX.Element {
// initialize analytics on load
useEffect(() => {
initExtensionAnalytics().catch(() => undefined)
}, [])
const isLoggedIn = useIsWalletUnlocked()
const hasSentAppLoadEvent = useRef(false)
useEffect(() => {
if (isLoggedIn !== null && !hasSentAppLoadEvent.current) {
hasSentAppLoadEvent.current = true
sendAnalyticsEvent(SharedEventName.APP_LOADED)
sendAnalyticsEvent(ExtensionEventName.SidebarLoad, { locked: !isLoggedIn })
}
}, [isLoggedIn])
return (
<PersistGate persistor={getReduxPersistor()}>
<BaseAppContainer appName={DatadogAppNameTag.Sidebar}>
<DappContextProvider>
<SidebarContent />
</DappContextProvider>
</BaseAppContainer>
</PersistGate>
)
}
@@ -0,0 +1,43 @@
import { useQuery } from '@tanstack/react-query'
import { SharedQueryClient } from '@universe/api'
import { StatsigCustomAppValue, StatsigUser } from '@universe/gating'
import { useEffect, useState } from 'react'
import { makeStatsigUser } from 'src/app/core/initStatSigForBrowserScripts'
import { StatsigProviderWrapper } from 'uniswap/src/features/gating/StatsigProviderWrapper'
import { initializeDatadog } from 'uniswap/src/utils/datadog'
import { uniqueIdQuery } from 'utilities/src/device/uniqueIdQuery'
export function ExtensionStatsigProvider({
children,
appName,
}: {
children: React.ReactNode
appName: string
}): JSX.Element {
const { data: uniqueId } = useQuery(uniqueIdQuery(), SharedQueryClient)
const [initFinished, setInitFinished] = useState(false)
const [user, setUser] = useState<StatsigUser>({
userID: undefined,
custom: {
app: StatsigCustomAppValue.Extension,
},
appVersion: process.env.VERSION,
})
useEffect(() => {
if (uniqueId && initFinished) {
setUser(makeStatsigUser(uniqueId))
}
}, [uniqueId, initFinished])
const onStatsigInit = (): void => {
setInitFinished(true)
initializeDatadog(appName).catch(() => undefined)
}
return (
<StatsigProviderWrapper user={user} onInit={onStatsigInit}>
{children}
</StatsigProviderWrapper>
)
}
@@ -0,0 +1,146 @@
import '@tamagui/core/reset.css'
import 'src/app/Global.css'
import { PropsWithChildren, useEffect } from 'react'
import { createHashRouter, Outlet, RouterProvider, useSearchParams } from 'react-router'
import { ErrorElement } from 'src/app/components/ErrorElement'
import { BaseAppContainer } from 'src/app/core/BaseAppContainer'
import { DatadogAppNameTag } from 'src/app/datadog'
import {
ClaimUnitagSteps,
OnboardingStepsProvider,
useOnboardingSteps,
} from 'src/app/features/onboarding/OnboardingSteps'
import { EditUnitagProfileScreen } from 'src/app/features/unitags/EditUnitagProfileScreen'
import { UnitagChooseProfilePicScreen } from 'src/app/features/unitags/UnitagChooseProfilePicScreen'
import { UnitagClaimBackground } from 'src/app/features/unitags/UnitagClaimBackground'
import { UnitagClaimContextProvider } from 'src/app/features/unitags/UnitagClaimContext'
import { UnitagConfirmationScreen } from 'src/app/features/unitags/UnitagConfirmationScreen'
import { UnitagCreateUsernameScreen } from 'src/app/features/unitags/UnitagCreateUsernameScreen'
import { UnitagIntroScreen } from 'src/app/features/unitags/UnitagIntroScreen'
import { UnitagClaimRoutes } from 'src/app/navigation/constants'
import { setRouter, setRouterState } from 'src/app/navigation/state'
import { initExtensionAnalytics } from 'src/app/utils/analytics'
import { Flex } from 'ui/src'
import { logger } from 'utilities/src/logger/logger'
import { usePrevious } from 'utilities/src/react/hooks'
import { useTestnetModeForLoggingAndAnalytics } from 'wallet/src/features/testnetMode/hooks/useTestnetModeForLoggingAndAnalytics'
import { useAccountAddressFromUrlWithThrow } from 'wallet/src/features/wallet/hooks'
const router = createHashRouter([
{
path: '',
element: <UnitagAppInner />,
children: [
{
path: UnitagClaimRoutes.ClaimIntro,
element: <UnitagClaimFlow />,
errorElement: <ErrorElement />,
},
{
path: UnitagClaimRoutes.EditProfile,
element: <UnitagEditProfileFlow />,
errorElement: <ErrorElement />,
},
],
},
])
/**
* 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
*/
// oxlint-disable-next-line typescript/no-explicit-any -- Router state object has dynamic structure from react-router
router.subscribe((state: any) => {
setRouterState(state)
})
setRouter(router)
function UnitagAppInner(): JSX.Element {
const [searchParams, setSearchParams] = useSearchParams()
const address = useAccountAddressFromUrlWithThrow()
const prevAddress = usePrevious(address)
// Ensures that address in url search params is consistent with hook
useEffect(() => {
if (searchParams.get('address') !== address) {
setSearchParams({ address })
}
}, [searchParams, address, setSearchParams])
useEffect(() => {
if (prevAddress && address !== prevAddress) {
// needed to reload on address param change for hash router
router
.navigate(0)
// oxlint-disable-next-line typescript/no-explicit-any -- Router state object has dynamic structure from react-router
.catch((e: any) => logger.error(e, { tags: { file: 'UnitagClaimApp.tsx', function: 'UnitagClaimAppInner' } }))
}
}, [address, prevAddress])
useTestnetModeForLoggingAndAnalytics()
return <Outlet />
}
function UnitagClaimFlow(): JSX.Element {
return (
<Flex centered height="100%" width="100%">
<OnboardingStepsProvider
disableRedirect
steps={{
[ClaimUnitagSteps.Intro]: <UnitagIntroScreen />,
[ClaimUnitagSteps.CreateUsername]: <UnitagCreateUsernameScreen />,
[ClaimUnitagSteps.ChooseProfilePic]: <UnitagChooseProfilePicScreen />,
[ClaimUnitagSteps.Confirmation]: <UnitagConfirmationScreen />,
[ClaimUnitagSteps.EditProfile]: <EditUnitagProfileScreen enableBack />,
}}
ContainerComponent={UnitagClaimAppWrapper}
/>
<Outlet />
</Flex>
)
}
function UnitagClaimAppWrapper({ children }: PropsWithChildren): JSX.Element {
const { step } = useOnboardingSteps()
const blurAllBackground = step !== ClaimUnitagSteps.Intro
return (
<UnitagClaimContextProvider>
<UnitagClaimBackground blurAll={blurAllBackground}>{children}</UnitagClaimBackground>
</UnitagClaimContextProvider>
)
}
function UnitagEditProfileFlow(): JSX.Element {
return (
<Flex centered height="100%" width="100%">
<OnboardingStepsProvider
disableRedirect
steps={{
[ClaimUnitagSteps.EditProfile]: <EditUnitagProfileScreen />,
}}
ContainerComponent={UnitagClaimAppWrapper}
/>
<Outlet />
</Flex>
)
}
// TODO WALL-4876 combine this with `PopupApp`
export default function UnitagClaimApp(): JSX.Element {
// initialize analytics on load
useEffect(() => {
initExtensionAnalytics().catch(() => undefined)
}, [])
return (
<BaseAppContainer appName={DatadogAppNameTag.UnitagClaim}>
<RouterProvider router={router} />
</BaseAppContainer>
)
}
@@ -0,0 +1,25 @@
import { StatsigClient, StatsigCustomAppValue, StatsigUser } from '@universe/gating'
import { config } from 'uniswap/src/config'
import { statsigBaseConfig } from 'uniswap/src/features/gating/statsigBaseConfig'
import { getUniqueId } from 'utilities/src/device/uniqueId'
import { logger } from 'utilities/src/logger/logger'
export function makeStatsigUser(userID: string): StatsigUser {
return {
userID,
appVersion: process.env.VERSION,
custom: {
app: StatsigCustomAppValue.Extension,
},
}
}
export async function initStatSigForBrowserScripts(): Promise<void> {
const uniqueId = await getUniqueId()
const statsigClient = new StatsigClient(config.statsigApiKey, makeStatsigUser(uniqueId), statsigBaseConfig)
await statsigClient.initializeAsync().catch((error) => {
logger.error(error, {
tags: { file: 'initStatSigForBrowserScripts.tsx', function: 'initStatSigForBrowserScripts' },
})
})
}
+8
View File
@@ -0,0 +1,8 @@
export const enum DatadogAppNameTag {
Sidebar = 'sidebar',
Onboarding = 'onboarding',
ContentScript = 'content-script',
Background = 'background',
Popup = 'popup',
UnitagClaim = 'unitag-claim',
}
@@ -0,0 +1,3 @@
export enum GlobalErrorEvent {
ReduxStorageExceeded = 'ReduxStorageExceeded',
}
+5
View File
@@ -0,0 +1,5 @@
import EventEmitter from 'eventemitter3'
import { GlobalErrorEvent } from 'src/app/events/constants'
class GlobalEventEmitter extends EventEmitter<GlobalErrorEvent> {}
export const globalEventEmitter = new GlobalEventEmitter()
@@ -0,0 +1,181 @@
import { SharedEventName } from '@uniswap/analytics-events'
import { 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 { AppRoutes, SettingsRoutes, UnitagClaimRoutes } from 'src/app/navigation/constants'
import { focusOrCreateUnitagTab, useExtensionNavigation } from 'src/app/navigation/utils'
import { Flex, Text, TouchableArea } from 'ui/src'
import { CopySheets, Edit, Ellipsis, Globe, TrashFilled } from 'ui/src/components/icons'
import { iconSizes } from 'ui/src/theme'
import { AddressDisplay } from 'uniswap/src/components/accounts/AddressDisplay'
import { ContextMenu, MenuOptionItem } from 'uniswap/src/components/menus/ContextMenu'
import { ContextMenuTriggerMode } from 'uniswap/src/components/menus/types'
import { WarningSeverity } from 'uniswap/src/components/modals/WarningModal/types'
import { WarningModal } from 'uniswap/src/components/modals/WarningModal/WarningModal'
import { DisplayNameType } from 'uniswap/src/features/accounts/types'
import { useLocalizationContext } from 'uniswap/src/features/language/LocalizationContext'
import { pushNotification } from 'uniswap/src/features/notifications/slice/slice'
import { AppNotificationType, CopyNotificationType } from 'uniswap/src/features/notifications/slice/types'
import { ElementName, ModalName } from 'uniswap/src/features/telemetry/constants'
import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send'
import { setClipboard } from 'utilities/src/clipboard/clipboard'
import { NumberType } from 'utilities/src/format/types'
import { useBooleanState } from 'utilities/src/react/useBooleanState'
import { EditAccountAction, editAccountActions } from 'wallet/src/features/wallet/accounts/editAccountSaga'
import { useDisplayName, useSignerAccounts } from 'wallet/src/features/wallet/hooks'
type AccountItemProps = {
address: Address
onAccountSelect?: () => void
balanceUSD?: number
}
export function AccountItem({ address, onAccountSelect, balanceUSD }: AccountItemProps): JSX.Element {
const { t } = useTranslation()
const dispatch = useDispatch()
const { navigateTo } = useExtensionNavigation()
const { convertFiatAmountFormatted } = useLocalizationContext()
const formattedBalance = convertFiatAmountFormatted(balanceUSD, NumberType.PortfolioBalance)
const [showEditLabelModal, setShowEditLabelModal] = useState(false)
const { value: isContextMenuOpen, setTrue: openMenu, setFalse: closeMenu } = useBooleanState(false)
const accounts = useSignerAccounts()
const displayName = useDisplayName(address)
const accountHasUnitag = displayName?.type === DisplayNameType.Unitag
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 (): Promise<void> => {
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((): MenuOptionItem[] => {
return [
{
label: t('account.wallet.menu.copy.title'),
onPress: onPressCopyAddress,
Icon: CopySheets,
},
{
label: !accountHasUnitag
? t('account.wallet.menu.edit.title')
: t('settings.setting.wallet.action.editProfile'),
onPress: async (): Promise<void> => {
if (accountHasUnitag) {
await focusOrCreateUnitagTab(address, UnitagClaimRoutes.EditProfile)
} else {
setShowEditLabelModal(true)
}
},
Icon: Edit,
},
{
label: t('account.wallet.menu.manageConnections'),
onPress: async (): Promise<void> => {
navigateTo(`/${AppRoutes.Settings}/${SettingsRoutes.ManageConnections}?address=${address}`)
},
Icon: Globe,
},
{
label: t('account.wallet.menu.remove.title'),
onPress: (): void => {
setShowRemoveWalletModal(true)
},
textColor: '$statusCritical',
Icon: TrashFilled,
iconColor: '$statusCritical',
destructive: true,
},
]
}, [accountHasUnitag, onPressCopyAddress, navigateTo, t, address])
return (
<>
<WarningModal
caption={t('account.recoveryPhrase.remove.mnemonic.description')}
rejectText={t('common.button.cancel')}
acknowledgeText={t('common.button.continue')}
icon={<TrashFilled color="$statusCritical" size="$icon.24" strokeWidth="$spacing1" />}
isOpen={showRemoveWalletModal}
modalName={ModalName.RemoveWallet}
severity={WarningSeverity.High}
title={t('account.wallet.remove.title', { name: displayName?.name ?? '' })}
onClose={() => setShowRemoveWalletModal(false)}
onAcknowledge={onRemoveWallet}
/>
<EditLabelModal address={address} isOpen={showEditLabelModal} onClose={() => setShowEditLabelModal(false)} />
<TouchableArea
hoverable
backgroundColor="$surface1"
borderRadius="$rounded16"
cursor="pointer"
p="$padding12"
onPress={onAccountSelect}
>
<Flex centered fill group row gap="$spacing8" justifyContent="space-between">
<AddressDisplay
address={address}
captionVariant="body3"
showViewOnlyBadge={false}
size={iconSizes.icon40}
variant="subheading2"
/>
<ContextMenu
menuItems={menuOptions}
triggerMode={ContextMenuTriggerMode.Primary}
isOpen={isContextMenuOpen}
openMenu={openMenu}
closeMenu={closeMenu}
>
<Flex centered>
<Text $group-hover={{ opacity: 0 }} color="$neutral2" opacity={isContextMenuOpen ? 0 : 1} variant="body3">
{formattedBalance}
</Text>
<Flex
$group-hover={{ opacity: 1 }}
borderRadius="$roundedFull"
hoverStyle={{ backgroundColor: '$surface2Hovered' }}
opacity={isContextMenuOpen ? 1 : 0}
position="absolute"
p="$spacing8"
right={0}
>
<Ellipsis color="$neutral2" size="$icon.16" />
</Flex>
</Flex>
</ContextMenu>
</Flex>
</TouchableArea>
</>
)
}
@@ -0,0 +1,27 @@
/* oxlint-disable typescript/no-var-requires */
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()
})
})
@@ -0,0 +1,411 @@
import { FeatureFlags, useFeatureFlag } from '@universe/gating'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useDispatch, useSelector } from 'react-redux'
import { ScreenHeader } from 'src/app/components/layout/ScreenHeader'
import { AccountItem } from 'src/app/features/accounts/AccountItem'
import { CreateWalletModal } from 'src/app/features/accounts/CreateWalletModal'
import { EditLabelModal } from 'src/app/features/accounts/EditLabelModal'
import { useSortedAccountList } from 'src/app/features/accounts/useSortedAccountList'
import { updateDappConnectedAddressFromExtension } from 'src/app/features/dapp/actions'
import { useDappContext } from 'src/app/features/dapp/DappContext'
import { useDappConnectedAccounts } from 'src/app/features/dapp/hooks'
import { isConnectedAccount } from 'src/app/features/dapp/utils'
import { openPopup, PopupName } from 'src/app/features/popups/slice'
import { AppRoutes, RemoveRecoveryPhraseRoutes, SettingsRoutes, UnitagClaimRoutes } from 'src/app/navigation/constants'
import { navigate } from 'src/app/navigation/state'
import { focusOrCreateUnitagTab, useExtensionNavigation } from 'src/app/navigation/utils'
import { Button, Flex, Popover, ScrollView, Text, TouchableArea, useSporeColors } from 'ui/src'
import { Ellipsis, Globe, Person, TrashFilled, WalletFilled, X } from 'ui/src/components/icons'
import { spacing } from 'ui/src/theme'
import { AddressDisplay } from 'uniswap/src/components/accounts/AddressDisplay'
import { buildWrappedUrl } from 'uniswap/src/components/banners/shared/utils'
import { UniswapWrapped2025Card } from 'uniswap/src/components/banners/UniswapWrapped2025Card/UniswapWrapped2025Card'
import { ContextMenu, MenuOptionItem } from 'uniswap/src/components/menus/ContextMenu'
import { ContextMenuTriggerMode } from 'uniswap/src/components/menus/types'
import { WarningSeverity } from 'uniswap/src/components/modals/WarningModal/types'
import { WarningModal } from 'uniswap/src/components/modals/WarningModal/WarningModal'
import { UNISWAP_WEB_URL } from 'uniswap/src/constants/urls'
import { AccountType, DisplayNameType } from 'uniswap/src/features/accounts/types'
import { setHasDismissedUniswapWrapped2025Banner } from 'uniswap/src/features/behaviorHistory/slice'
import { Platform } from 'uniswap/src/features/platforms/types/Platform'
import { ModalName, WalletEventName } from 'uniswap/src/features/telemetry/constants'
import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send'
import Trace from 'uniswap/src/features/telemetry/Trace'
import { TestID } from 'uniswap/src/test/fixtures/testIDs'
import { ImportType } from 'uniswap/src/types/onboarding'
import { areAddressesEqual } from 'uniswap/src/utils/addresses'
import { logger } from 'utilities/src/logger/logger'
import { useBooleanState } from 'utilities/src/react/useBooleanState'
import { sleep } from 'utilities/src/time/timing'
import { PlusCircle } from 'wallet/src/components/icons/PlusCircle'
import { MenuContent } from 'wallet/src/components/menu/MenuContent'
import { MenuContentItem } from 'wallet/src/components/menu/types'
import { createOnboardingAccount } from 'wallet/src/features/onboarding/createOnboardingAccount'
import { useCanActiveAddressClaimUnitag } from 'wallet/src/features/unitags/hooks/useCanActiveAddressClaimUnitag'
import { BackupType, SignerMnemonicAccount } from 'wallet/src/features/wallet/accounts/types'
import { hasBackup } from 'wallet/src/features/wallet/accounts/utils'
import { createAccountsActions } from 'wallet/src/features/wallet/create/createAccountsSaga'
import {
useActiveAccountAddressWithThrow,
useActiveAccountWithThrow,
useDisplayName,
useSignerAccounts,
} from 'wallet/src/features/wallet/hooks'
import { selectSortedSignerMnemonicAccounts } from 'wallet/src/features/wallet/selectors'
import { setAccountAsActive } from 'wallet/src/features/wallet/slice'
const MIN_MENU_WIDTH = 200
export function AccountSwitcherScreen(): JSX.Element {
const colors = useSporeColors()
const dispatch = useDispatch()
const { t } = useTranslation()
const { navigateTo } = useExtensionNavigation()
const activeAccount = useActiveAccountWithThrow()
const activeAddress = activeAccount.address
const isViewOnly = activeAccount.type === AccountType.Readonly
const isWrappedBannerEnabled = useFeatureFlag(FeatureFlags.UniswapWrapped2025)
const accounts = useSignerAccounts()
const accountAddresses = useMemo(
() =>
accounts
.map((account) => account.address)
.filter(
(address) =>
!areAddressesEqual({
addressInput1: { address, platform: Platform.EVM },
addressInput2: { address: activeAddress, platform: Platform.EVM },
}),
),
[accounts, activeAddress],
)
const { dappUrl } = useDappContext()
const connectedAccounts = useDappConnectedAccounts(dappUrl)
// TODO: EXT-899 https://linear.app/uniswap/issue/EXT-899/enable-unitag-edit-button-is-account-header
const activeAccountDisplayName = useDisplayName(activeAddress)
const activeAccountHasUnitag = activeAccountDisplayName?.type === DisplayNameType.Unitag
const activeAccountHasENS = activeAccountDisplayName?.type === DisplayNameType.ENS
const [showEditLabelModal, setShowEditLabelModal] = useState(false)
const [showRemoveWalletModal, setShowRemoveWalletModal] = useState(false)
const [showImportWalletModal, setShowImportWalletModal] = useState(false)
const [showCreateWalletModal, setShowCreateWalletModal] = useState(false)
const { value: isEllipsisMenuOpen, setTrue: openEllipsisMenu, setFalse: closeEllipsisMenu } = useBooleanState(false)
const [pendingWallet, setPendingWallet] = useState<SignerMnemonicAccount>()
const sortedMnemonicAccounts = useSelector(selectSortedSignerMnemonicAccounts)
const { canClaimUnitag } = useCanActiveAddressClaimUnitag(activeAddress)
useEffect(() => {
const createOnboardingAccountAfterTransitionAnimation = async (): Promise<void> => {
// TODO: EXT-1164 - Move Keyring methods to workers to not block main thread during onboarding
// Delays computation heavy function invocation to avoid disrupting transition animation
await sleep(400)
setPendingWallet(await createOnboardingAccount(sortedMnemonicAccounts))
}
createOnboardingAccountAfterTransitionAnimation().catch((e) => {
logger.error(e, {
tags: { file: 'AccountSwitcherScreen', function: 'createOnboardingAccount' },
})
})
}, [sortedMnemonicAccounts])
const onNavigateToRemoveWallet = (): void => {
setShowRemoveWalletModal(false)
setShowImportWalletModal(false)
navigate(`/${AppRoutes.Settings}/${SettingsRoutes.RemoveRecoveryPhrase}/${RemoveRecoveryPhraseRoutes.Wallets}`)
}
const onCancelCreateWallet = (): void => {
setShowCreateWalletModal(false)
}
const onConfirmCreateWallet = useCallback(
async (walletLabel: string): Promise<void> => {
setShowCreateWalletModal(false)
if (!pendingWallet) {
return
}
if (walletLabel) {
pendingWallet.name = walletLabel
}
dispatch(
createAccountsActions.trigger({
accounts: [pendingWallet],
}),
)
sendAnalyticsEvent(WalletEventName.WalletAdded, {
wallet_type: ImportType.CreateAdditional,
accounts_imported_count: 1,
wallets_imported: [pendingWallet.address],
cloud_backup_used: hasBackup(BackupType.Cloud, pendingWallet),
modal: ModalName.AccountSwitcher,
})
navigate(-1)
// Only show connect popup if some account is connected to current dapp
if (connectedAccounts.length > 0) {
dispatch(openPopup(PopupName.Connect))
}
},
[connectedAccounts.length, dispatch, pendingWallet],
)
const onPressWrappedCard = useCallback(() => {
try {
const url = buildWrappedUrl(UNISWAP_WEB_URL, activeAddress)
window.open(url, '_blank')
dispatch(setHasDismissedUniswapWrapped2025Banner(true))
navigate(-1)
} catch (error) {
logger.error(error, { tags: { file: 'AccountSwitcherScreen', function: 'onPressWrappedCard' } })
}
}, [activeAddress, dispatch])
const addWalletMenuOptions: MenuContentItem[] = [
{
label: t('account.wallet.button.create'),
onPress: (): void => setShowCreateWalletModal(true),
},
{
label: t('account.wallet.button.import'),
onPress: (): void => setShowImportWalletModal(true),
},
]
const sortedAddressesByBalance = useSortedAccountList(accountAddresses)
const contentShadowProps = {
shadowColor: colors.shadowColor.val,
shadowRadius: 12,
shadowOpacity: 0.1,
zIndex: 1,
}
const menuOptions = useMemo((): MenuOptionItem[] => {
return [
...(canClaimUnitag
? [
{
label: t('account.wallet.menu.claimUsername'),
onPress: async (): Promise<void> => {
await focusOrCreateUnitagTab(activeAddress, UnitagClaimRoutes.ClaimIntro)
},
Icon: Person,
},
]
: []),
{
label: t('account.wallet.menu.manageConnections'),
onPress: (): void => {
navigateTo(`/${AppRoutes.Settings}/${SettingsRoutes.ManageConnections}?address=${activeAddress}`)
},
Icon: Globe,
},
{
label: t('account.wallet.menu.remove.title'),
onPress: (): void => {
setShowRemoveWalletModal(true)
},
textColor: '$statusCritical',
Icon: TrashFilled,
iconColor: '$statusCritical',
destructive: true,
},
]
}, [canClaimUnitag, activeAddress, navigateTo, t])
return (
<Trace logImpression modal={ModalName.AccountSwitcher}>
<EditLabelModal
address={activeAddress}
isOpen={showEditLabelModal}
onClose={() => setShowEditLabelModal(false)}
/>
<WarningModal
caption={
showImportWalletModal
? t('account.recoveryPhrase.remove.import.description')
: t('account.recoveryPhrase.remove.mnemonic.description', {
walletNames: [activeAccountDisplayName?.name ?? ''],
})
}
rejectText={t('common.button.cancel')}
acknowledgeText={t('common.button.continue')}
icon={<WalletFilled color="$statusCritical" size="$icon.24" />}
isOpen={showImportWalletModal || showRemoveWalletModal}
modalName={ModalName.RemoveWallet}
severity={WarningSeverity.High}
title={
showImportWalletModal
? t('account.wallet.button.import')
: t('account.wallet.remove.title', { name: activeAccountDisplayName?.name ?? '' })
}
onClose={() => {
setShowImportWalletModal(false)
setShowRemoveWalletModal(false)
}}
onAcknowledge={onNavigateToRemoveWallet}
/>
<CreateWalletModal
isOpen={showCreateWalletModal}
pendingWallet={pendingWallet}
onCancel={onCancelCreateWallet}
onConfirm={onConfirmCreateWallet}
/>
<Flex backgroundColor="$surface1" px="$spacing12" py="$spacing8">
<ScreenHeader
Icon={X}
rightColumn={
<ContextMenu
menuItems={menuOptions}
triggerMode={ContextMenuTriggerMode.Primary}
isOpen={isEllipsisMenuOpen}
openMenu={openEllipsisMenu}
closeMenu={closeEllipsisMenu}
>
<TouchableArea
borderRadius="$roundedFull"
hoverStyle={{ backgroundColor: '$surface2Hovered' }}
p="$spacing8"
>
<Ellipsis color="$neutral2" size="$icon.20" />
</TouchableArea>
</ContextMenu>
}
/>
<Flex pb="$spacing4" pt="$spacing8" px="$spacing12">
<Flex row alignSelf="stretch" width="100%" justifyContent="center">
<Flex flex={1} justifyContent="center">
<AddressDisplay
showCopy
centered
address={activeAddress}
captionVariant="body3"
direction="column"
horizontalGap="$spacing8"
showViewOnlyBadge={isViewOnly}
size={spacing.spacing60 - spacing.spacing4}
variant="subheading1"
/>
</Flex>
</Flex>
{isWrappedBannerEnabled && (
<Flex pt="$spacing16">
<UniswapWrapped2025Card onPress={onPressWrappedCard} />
</Flex>
)}
<Flex pt={activeAccountHasENS ? undefined : '$padding16'}>
{activeAccountHasUnitag ? (
<UnitagActionButton />
) : (
!activeAccountHasENS && (
<Flex row>
<Button
size="small"
testID={TestID.AccountCard}
emphasis="secondary"
onPress={() => setShowEditLabelModal(true)}
>
{t('account.wallet.header.button.title')}
</Button>
</Flex>
)
)}
</Flex>
</Flex>
<ScrollView backgroundColor="$surface1" height="auto">
<Flex pt="$padding20">
{sortedAddressesByBalance.map(({ address, balance }) => {
return (
<AccountItem
key={address}
address={address}
balanceUSD={balance}
onAccountSelect={async (): Promise<void> => {
dispatch(setAccountAsActive(address))
await updateDappConnectedAddressFromExtension(address)
if (connectedAccounts.length > 0 && !isConnectedAccount(connectedAccounts, address)) {
dispatch(openPopup(PopupName.Connect))
}
navigate(-1)
}}
/>
)
})}
</Flex>
<Popover offset={-spacing.spacing4} placement="top-start">
<Popover.Trigger>
<Flex
row
alignItems="center"
cursor="pointer"
gap="$spacing12"
mt="$spacing12"
pb="$spacing4"
px="$spacing12"
>
<PlusCircle />
<Text color="$neutral1" py="$spacing8" variant="buttonLabel2">
{t('account.wallet.button.add')}
</Text>
</Flex>
</Popover.Trigger>
<Popover.Content
animation={[
'quick',
{
opacity: {
overshootClamping: true,
},
},
]}
borderColor="$surface3"
borderRadius="$rounded16"
borderWidth="$spacing1"
enableRemoveScroll={true}
enterStyle={{ y: -10, opacity: 0 }}
exitStyle={{ y: -10, opacity: 0 }}
p="$none"
{...contentShadowProps}
>
<MenuContent items={addWalletMenuOptions} minWidth={MIN_MENU_WIDTH} />
</Popover.Content>
</Popover>
</ScrollView>
</Flex>
</Trace>
)
}
const UnitagActionButton = (): JSX.Element => {
const { t } = useTranslation()
const address = useActiveAccountAddressWithThrow()
const onPressEditProfile = useCallback(async () => {
await focusOrCreateUnitagTab(address, UnitagClaimRoutes.EditProfile)
}, [address])
return (
<Flex row>
<Button size="small" testID={TestID.AccountCard} emphasis="secondary" onPress={onPressEditProfile}>
{t('account.wallet.header.button.disabled.title')}
</Button>
</Flex>
)
}
@@ -0,0 +1,76 @@
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button, Flex, Text } from 'ui/src'
import { iconSizes } from 'ui/src/theme'
import { TextInput } from 'uniswap/src/components/input/TextInput'
import { Modal } from 'uniswap/src/components/modals/Modal'
import { AccountIcon } from 'uniswap/src/features/accounts/AccountIcon'
import { ModalName } from 'uniswap/src/features/telemetry/constants'
import { shortenAddress } from 'utilities/src/addresses'
import { SignerMnemonicAccount } from 'wallet/src/features/wallet/accounts/types'
type CreateWalletModalProps = {
isOpen: boolean
pendingWallet?: SignerMnemonicAccount
onCancel: () => void
onConfirm: (walletLabel: string) => void
}
// Expects a pending account to be created before opening this modal
export function CreateWalletModal({
isOpen,
pendingWallet,
onCancel,
onConfirm,
}: CreateWalletModalProps): JSX.Element | null {
const { t } = useTranslation()
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 })
: ''
return (
<Modal isModalOpen={isOpen} 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({ address: onboardingAccountAddress })}
</Text>
)}
</Flex>
<Flex row alignSelf="stretch" gap="$spacing12">
<Button size="small" emphasis="secondary" onPress={onCancel}>
{t('common.button.cancel')}
</Button>
<Button variant="branded" emphasis="secondary" size="small" onPress={onPressConfirm}>
{t('common.button.create')}
</Button>
</Flex>
</Flex>
</Modal>
)
}
@@ -0,0 +1,110 @@
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useDispatch } from 'react-redux'
import { UnitagClaimRoutes } from 'src/app/navigation/constants'
import { focusOrCreateUnitagTab } from 'src/app/navigation/utils'
import { Button, Flex, Text } from 'ui/src'
import { Person } from 'ui/src/components/icons'
import { iconSizes } from 'ui/src/theme'
import { TextInput } from 'uniswap/src/components/input/TextInput'
import { Modal } from 'uniswap/src/components/modals/Modal'
import { AccountIcon } from 'uniswap/src/features/accounts/AccountIcon'
import { DisplayNameType } from 'uniswap/src/features/accounts/types'
import { ModalName } from 'uniswap/src/features/telemetry/constants'
import { OnboardingCardLoggingName } from 'uniswap/src/features/telemetry/types'
import { UNITAG_SUFFIX_NO_LEADING_DOT } from 'uniswap/src/features/unitags/constants'
import { shortenAddress } from 'utilities/src/addresses'
import { CardType, IntroCard, IntroCardGraphicType } from 'wallet/src/components/introCards/IntroCard'
import { useCanActiveAddressClaimUnitag } from 'wallet/src/features/unitags/hooks/useCanActiveAddressClaimUnitag'
import { EditAccountAction, editAccountActions } from 'wallet/src/features/wallet/accounts/editAccountSaga'
import { useDisplayName } from 'wallet/src/features/wallet/hooks'
type EditLabelModalProps = {
isOpen: boolean
address: Address
onClose: () => void
}
export function EditLabelModal({ isOpen, 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 { canClaimUnitag } = useCanActiveAddressClaimUnitag(address)
const onConfirm = useCallback(async () => {
await dispatch(
editAccountActions.trigger({
type: EditAccountAction.Rename,
address,
newName: inputText,
}),
)
onClose()
}, [address, dispatch, inputText, onClose])
const navigateToUnitagClaim = useCallback(async () => {
await focusOrCreateUnitagTab(address, UnitagClaimRoutes.ClaimIntro)
}, [address])
const unitagClaimCard = (
<IntroCard
loggingName={OnboardingCardLoggingName.ClaimUnitag}
graphic={{ type: IntroCardGraphicType.Icon, Icon: Person }}
title={t('onboarding.home.intro.unitag.title', {
unitagDomain: UNITAG_SUFFIX_NO_LEADING_DOT,
})}
description={t('onboarding.home.intro.unitag.description')}
cardType={CardType.Default}
containerProps={{
borderWidth: 0,
backgroundColor: '$surface1',
}}
onPress={navigateToUnitagClaim}
/>
)
return (
<Modal
isModalOpen={isOpen}
name={ModalName.AccountEditLabel}
bottomAttachment={canClaimUnitag ? unitagClaimCard : undefined}
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 flexBasis={1} size="small" emphasis="secondary" onPress={onClose}>
{t('common.button.cancel')}
</Button>
<Button flexBasis={1} size="small" variant="branded" emphasis="secondary" onPress={onConfirm}>
{t('common.button.save')}
</Button>
</Flex>
</Flex>
</Modal>
)
}
@@ -0,0 +1,734 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AccountSwitcherScreen renders correctly 1`] = `
{
"asFragment": [Function],
"baseElement": <body>
<div>
<span
class=""
style="display: contents;"
>
<span
class="font_unset _display-inline _boxSizing-border-box _wordWrap-break-word _whiteSpace-pre-wrap _position-absolute _width-1px _height-1px _mt--1px _mr--1px _mb--1px _ml--1px _zIndex--10000 _overflowX-hidden _overflowY-hidden _opacity-1e-8 _pointerEvents-none"
>
<h2
class="is_DialogTitle font_heading _display-inline _boxSizing-border-box _wordWrap-break-word _userSelect-auto _color-color _whiteSpace-normal _fontFamily-f-family _mt-0px _mr-0px _mb-0px _ml-0px"
data-disable-theme="true"
id="Dialog--:r5:-title"
role="heading"
/>
</span>
<span
class="font_unset _display-inline _boxSizing-border-box _wordWrap-break-word _whiteSpace-pre-wrap _position-absolute _width-1px _height-1px _mt--1px _mr--1px _mb--1px _ml--1px _zIndex--10000 _overflowX-hidden _overflowY-hidden _opacity-1e-8 _pointerEvents-none"
>
<h2
class="is_DialogTitle font_heading _display-inline _boxSizing-border-box _wordWrap-break-word _userSelect-auto _color-color _whiteSpace-normal _fontFamily-f-family _mt-0px _mr-0px _mb-0px _ml-0px"
data-disable-theme="true"
id="Dialog--:rg:-title"
role="heading"
/>
</span>
<span
class="font_unset _display-inline _boxSizing-border-box _wordWrap-break-word _whiteSpace-pre-wrap _position-absolute _width-1px _height-1px _mt--1px _mr--1px _mb--1px _ml--1px _zIndex--10000 _overflowX-hidden _overflowY-hidden _opacity-1e-8 _pointerEvents-none"
>
<h2
class="is_DialogTitle font_heading _display-inline _boxSizing-border-box _wordWrap-break-word _userSelect-auto _color-color _whiteSpace-normal _fontFamily-f-family _mt-0px _mr-0px _mb-0px _ml-0px"
data-disable-theme="true"
id="Dialog--:rq:-title"
role="heading"
/>
</span>
<div
class="_display-flex _alignItems-stretch _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0 _flexDirection-column _backgroundColor-surface1 _pr-t-space-spa1360334080 _pl-t-space-spa1360334080 _pt-t-space-spa94665593 _pb-t-space-spa94665593"
>
<div
class="_display-flex _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0 _flexDirection-row _alignItems-center _pr-t-space-spa94665593 _pl-t-space-spa94665593 _pt-t-space-spa94665589 _pb-t-space-spa94665589 _width-10037"
>
<div
class="is_TouchableArea t_group_true"
role="button"
style="display: flex; align-items: stretch; flex-basis: auto; box-sizing: border-box; position: relative; min-height: 0px; min-width: 0px; flex-shrink: 0; flex-direction: column; border-top-left-radius: var(--t-radius-rounded12); border-top-right-radius: var(--t-radius-rounded12); border-bottom-right-radius: var(--t-radius-rounded12); border-bottom-left-radius: var(--t-radius-rounded12); cursor: pointer; outline-color: rgba(0, 0, 0, 0); opacity: 1; transform: scale(1);"
tabindex="0"
>
<svg
fill="none"
stroke-width="8"
style="width: 24px; height: 24px; color: rgba(19, 19, 19, 0.63);"
viewBox="0 0 16 16"
>
<path
d="M12.5303 4.53033C12.8232 4.23744 12.8232 3.76256 12.5303 3.46967C12.2374 3.17678 11.7626 3.17678 11.4697 3.46967L12.5303 4.53033ZM3.46967 11.4697C3.17678 11.7626 3.17678 12.2374 3.46967 12.5303C3.76256 12.8232 4.23744 12.8232 4.53033 12.5303L3.46967 11.4697ZM4.53033 3.46967C4.23744 3.17678 3.76256 3.17678 3.46967 3.46967C3.17678 3.76256 3.17678 4.23744 3.46967 4.53033L4.53033 3.46967ZM11.4697 12.5303C11.7626 12.8232 12.2374 12.8232 12.5303 12.5303C12.8232 12.2374 12.8232 11.7626 12.5303 11.4697L11.4697 12.5303ZM11.4697 3.46967L3.46967 11.4697L4.53033 12.5303L12.5303 4.53033L11.4697 3.46967ZM3.46967 4.53033L11.4697 12.5303L12.5303 11.4697L4.53033 3.46967L3.46967 4.53033Z"
fill="currentColor"
/>
</svg>
</div>
<div
class="_display-flex _alignItems-center _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-1 _flexDirection-column _justifyContent-center _flexGrow-1 _mr-t-space-non101 _pt-t-space-spa94665593 _pb-t-space-spa94665593"
>
<span
class="font_subHeading _display-inline _boxSizing-border-box _whiteSpace-pre-wrap _mt-0px _mr-0px _mb-0px _ml-0px _color-neutral1 _fontFamily-f-family _wordWrap-break-word _fontSize-f-size-larg101 _fontWeight-f-weight-bo3548 _lineHeight-f-lineHeigh1262266530"
data-disable-theme="true"
>
</span>
</div>
<div
class="_display-flex _alignItems-stretch _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0 _flexDirection-column"
>
<div
aria-expanded="false"
aria-haspopup="dialog"
class="_display-flex _alignItems-stretch _flexDirection-column _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0"
>
<div
aria-expanded="false"
class="_display-flex _alignItems-stretch _flexDirection-column _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0"
data-state="closed"
>
<div>
<div
class="is_TouchableArea t_group_true"
role="button"
style="display: flex; align-items: stretch; flex-basis: auto; box-sizing: border-box; position: relative; min-height: 0px; min-width: 0px; flex-shrink: 0; flex-direction: column; cursor: pointer; outline-color: rgba(0, 0, 0, 0); opacity: 1; border-top-left-radius: var(--t-radius-roundedFull); border-top-right-radius: var(--t-radius-roundedFull); border-bottom-right-radius: var(--t-radius-roundedFull); border-bottom-left-radius: var(--t-radius-roundedFull); transform: scale(1);"
tabindex="0"
>
<svg
fill="none"
stroke-width="8"
style="width: 20px; height: 20px; color: rgba(19, 19, 19, 0.63);"
viewBox="0 0 18 4"
>
<path
d="M9 3C9.55228 3 10 2.55228 10 2C10 1.44772 9.55228 1 9 1C8.44772 1 8 1.44772 8 2C8 2.55228 8.44772 3 9 3Z"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
<path
d="M16 3C16.5523 3 17 2.55228 17 2C17 1.44772 16.5523 1 16 1C15.4477 1 15 1.44772 15 2C15 2.55228 15.4477 3 16 3Z"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
<path
d="M2 3C2.55228 3 3 2.55228 3 2C3 1.44772 2.55228 1 2 1C1.44772 1 1 1.44772 1 2C1 2.55228 1.44772 3 2 3Z"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</div>
</div>
</div>
</div>
</div>
</div>
<div
class="_display-flex _alignItems-stretch _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0 _flexDirection-column _pb-t-space-spa94665589 _pt-t-space-spa94665593 _pr-t-space-spa1360334080 _pl-t-space-spa1360334080"
>
<div
class="_display-flex _alignItems-stretch _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0 _flexDirection-row _alignSelf-stretch _width-10037 _justifyContent-center"
>
<div
class="_display-flex _alignItems-stretch _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-1 _flexDirection-column _flexGrow-1 _justifyContent-center"
>
<div
class="_display-flex _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-1 _flexDirection-column _gap-t-space-spa94665593 _alignItems-center"
>
<div
class="_display-flex _alignItems-center _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0 _flexDirection-column _justifyContent-center"
>
<div
class="_display-flex _alignItems-stretch _flexBasis-auto _boxSizing-border-box _minHeight-0px _minWidth-0px _flexShrink-0 _flexDirection-column _backgroundColor-transparent _borderTopColor-transparent _borderRightColor-transparent _borderBottomColor-transparent _borderLeftColor-transparent _borderTopLeftRadius-t-radius-ro1041013639 _borderTopRightRadius-t-radius-ro1041013639 _borderBottomRightRadius-t-radius-ro1041013639 _borderBottomLeftRadius-t-radius-ro1041013639 _borderTopWidth-t-space-non101 _borderRightWidth-t-space-non101 _borderBottomWidth-t-space-non101 _borderLeftWidth-t-space-non101 _position-relative _borderBottomStyle-solid _borderTopStyle-solid _borderLeftStyle-solid _borderRightStyle-solid"
data-testid="account-icon"
>
<div
class="t_unmounted t_unmounted"
style="display: flex; align-items: stretch; flex-basis: auto; box-sizing: border-box; position: relative; min-height: 0px; min-width: 0px; flex-shrink: 0; flex-direction: column; opacity: 0; transition: all 100ms cubic-bezier(0.17, 0.67, 0.45, 1);"
>
<svg
height="56"
viewBox="0 0 56 56"
width="56"
xmlns="http://www.w3.org/2000/svg"
>
<g
style="transform-origin: center center;"
>
<circle
cx="28"
cy="28"
fill="#FFBF171F"
r="28"
/>
<g
transform="translate(9.333333333333332, 9.333333333333332) scale(0.7777777777777778)"
/>
</g>
</svg>
</div>
</div>
</div>
<div
class="_display-flex _alignItems-stretch _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-1 _flexDirection-column _gap-t-space-non101"
>
<div
class="_display-flex _alignItems-stretch _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0 _flexDirection-row _gap-t-space-spa1360334080"
>
<div
class="_display-flex _alignItems-center _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexDirection-row _flexGrow-1 _gap-t-space-spa94665589 _flexShrink-1 _justifyContent-center"
>
<span
class="font_heading _display-inline _boxSizing-border-box _mt-0px _mr-0px _mb-0px _ml-0px _color-neutral1 _wordWrap-break-word _fontFamily-f-family _fontSize-18px _fontWeight-f-weight-bo3548 _lineHeight-24px _maxWidth-10037 _overflowX-hidden _overflowY-hidden _textOverflow-ellipsis _whiteSpace-initial _textAlign-center _flexShrink-1"
data-disable-theme="true"
data-testid="address-display/name/undefined"
/>
</div>
</div>
<div
class="_display-flex _alignItems-center _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0 _flexDirection-column _justifyContent-center"
>
<div
class="is_TouchableArea t_group_true"
data-testid="copy"
role="button"
style="display: flex; align-items: stretch; flex-basis: auto; box-sizing: border-box; position: relative; min-height: 0px; min-width: 0px; flex-shrink: 0; flex-direction: column; border-top-left-radius: var(--t-radius-rounded12); border-top-right-radius: var(--t-radius-rounded12); border-bottom-right-radius: var(--t-radius-rounded12); border-bottom-left-radius: var(--t-radius-rounded12); cursor: pointer; outline-color: rgba(0, 0, 0, 0); opacity: 1; transform: scale(1);"
tabindex="0"
>
<div
class="_color-_grouptrue-hover_accent3Hove112785 _backgroundColor-_grouptrue-hover_transparent _display-flex _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0 _flexDirection-row _alignItems-center _justifyContent-center _backgroundColor-transparent _borderTopLeftRadius-t-radius-ro1041013639 _borderTopRightRadius-t-radius-ro1041013639 _borderBottomRightRadius-t-radius-ro1041013639 _borderBottomLeftRadius-t-radius-ro1041013639 _gap-t-space-spa94665589 _mt-t-space-non101 _pr-t-space-non101 _pl-t-space-non101 _pt-t-space-non101 _pb-t-space-non101 _color-accent3"
>
<span
class="font_body _display-inline _boxSizing-border-box _whiteSpace-pre-wrap _mt-0px _mr-0px _mb-0px _ml-0px _color-neutral2 _fontFamily-f-family _wordWrap-break-word _fontSize-f-size-smal108 _fontWeight-f-weight-bo3548 _lineHeight-f-lineHeigh1269072494"
data-disable-theme="true"
>
0x9EB67f...D9A2Ca
</span>
<div
class="_display-flex _alignItems-stretch _flexBasis-auto _boxSizing-border-box _minHeight-0px _minWidth-0px _flexShrink-0 _flexDirection-column _position-relative _width-14px _height-14px"
>
<div
class="_display-flex _alignItems-stretch _flexBasis-auto _boxSizing-border-box _minHeight-0px _minWidth-0px _flexShrink-0 _flexDirection-column _position-absolute _top-0px _left-0px"
>
<div
style="display: flex; align-items: stretch; flex-basis: auto; box-sizing: border-box; position: relative; min-height: 0px; min-width: 0px; flex-shrink: 0; flex-direction: column; opacity: 1; width: 100%; flex-grow: 1; transform: translateX(0px); transition: all 300ms ease-in-out;"
>
<svg
fill="none"
stroke-width="8"
style="width: 14px; height: 14px; color: rgba(19, 19, 19, 0.63);"
viewBox="0 0 24 24"
>
<path
d="M18.375 6.25H9.625C7.448 6.25 6.25 7.448 6.25 9.625V18.375C6.25 20.552 7.448 21.75 9.625 21.75H18.375C20.552 21.75 21.75 20.552 21.75 18.375V9.625C21.75 7.448 20.552 6.25 18.375 6.25ZM20.25 18.375C20.25 19.707 19.707 20.25 18.375 20.25H9.625C8.293 20.25 7.75 19.707 7.75 18.375V9.625C7.75 8.293 8.293 7.75 9.625 7.75H18.375C19.707 7.75 20.25 8.293 20.25 9.625V18.375ZM3.75 5.62V14.38C3.75 15.578 4.23309 15.873 4.39209 15.971C4.74609 16.187 4.85589 16.649 4.63989 17.002C4.49789 17.233 4.25202 17.36 3.99902 17.36C3.86602 17.36 3.72991 17.324 3.60791 17.25C2.70691 16.698 2.25 15.733 2.25 14.38V5.62C2.25 3.478 3.47912 2.25 5.62012 2.25H14.3799C16.0649 2.25 16.87 2.98897 17.25 3.60797C17.466 3.96097 17.355 4.42298 17.002 4.63898C16.648 4.85598 16.1879 4.74399 15.9709 4.39099C15.8739 4.23199 15.5779 3.74902 14.3799 3.74902H5.62012C4.29212 3.75002 3.75 4.292 3.75 5.62Z"
fill="currentColor"
/>
</svg>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div
class="_display-flex _alignItems-stretch _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0 _flexDirection-column _pt-t-space-pad1331704925"
>
<div
class="_display-flex _alignItems-stretch _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0 _flexDirection-row"
>
<button
class="is_Button t_group_item"
data-testid="account-card"
dd-action-name="Edit label"
style="display: flex; flex-basis: 0px; box-sizing: border-box; position: relative; min-height: 0px; min-width: 0px; flex-shrink: 1; flex-direction: row; border-top-width: 1px; border-right-width: 1px; border-bottom-width: 1px; border-left-width: 1px; align-items: center; justify-content: center; cursor: pointer; height: auto; align-self: stretch; flex-grow: 1; outline-color: rgba(0, 0, 0, 0); border-top-left-radius: var(--t-radius-rounded12); border-top-right-radius: var(--t-radius-rounded12); border-bottom-right-radius: var(--t-radius-rounded12); border-bottom-left-radius: var(--t-radius-rounded12); gap: var(--t-space-spacing8); border-bottom-style: solid; border-top-style: solid; border-left-style: solid; border-right-style: solid; transform: scale(1); transition: transform 100ms cubic-bezier(0.17, 0.67, 0.45, 1);"
>
<span
class="font_button _color-_groupitem-hover_neutral1Hov3121676 _display-inline _boxSizing-border-box _wordWrap-break-word _whiteSpace-nowrap _mt-0px _mr-0px _mb-0px _ml-0px _color-neutral1 _fontFamily-f-family _maxWidth-10037 _overflowX-hidden _overflowY-hidden _textOverflow-ellipsis _textAlign-center _fontSize-f-size-smal108 _fontWeight-f-weight-me3083741 _lineHeight-f-lineHeigh1269072494"
line-height-disabled="false"
>
Edit label
</span>
</button>
</div>
</div>
</div>
<span
class="_dsp_contents"
>
<div
class="css-view-175oi2r r-WebkitOverflowScrolling-150rngu r-flexDirection-eqz5dr r-flexGrow-16y2uox r-flexShrink-1wbh5a2 r-overflowX-11yh6sk r-overflowY-1rnoaur r-transform-agouwx is_ScrollView _backgroundColor-surface1 _height-auto"
>
<div
class="css-view-175oi2r"
>
<div
class="_display-flex _alignItems-stretch _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0 _flexDirection-column _pt-t-space-pad1331704900"
/>
<div
aria-expanded="false"
aria-haspopup="dialog"
class="_display-flex _alignItems-stretch _flexDirection-column _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0"
>
<div
aria-expanded="false"
class="_display-flex _alignItems-stretch _flexDirection-column _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0"
data-state="closed"
>
<div
class="_display-flex _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0 _flexDirection-row _alignItems-center _cursor-pointer _gap-t-space-spa1360334080 _mt-t-space-spa1360334080 _pb-t-space-spa94665589 _pr-t-space-spa1360334080 _pl-t-space-spa1360334080"
>
<div
class="_display-flex _alignItems-center _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0 _flexDirection-column _justifyContent-center _backgroundColor-surface1 _borderTopColor-surface3 _borderRightColor-surface3 _borderBottomColor-surface3 _borderLeftColor-surface3 _borderTopLeftRadius-t-radius-ro1041013639 _borderTopRightRadius-t-radius-ro1041013639 _borderBottomRightRadius-t-radius-ro1041013639 _borderBottomLeftRadius-t-radius-ro1041013639 _borderTopWidth-t-space-spa94665586 _borderRightWidth-t-space-spa94665586 _borderBottomWidth-t-space-spa94665586 _borderLeftWidth-t-space-spa94665586 _height-32px _pt-t-space-spa94665593 _pr-t-space-spa94665593 _pb-t-space-spa94665593 _pl-t-space-spa94665593 _width-32px _borderBottomStyle-solid _borderTopStyle-solid _borderLeftStyle-solid _borderRightStyle-solid _boxShadow-0px0px10pxv169431092"
>
<svg
fill="none"
stroke-width="2"
style="width: 16px; height: 16px; color: rgba(19, 19, 19, 0.63);"
viewBox="0 0 24 24"
>
<path
d="M19 11H13V5C13 4.448 12.552 4 12 4C11.448 4 11 4.448 11 5V11H5C4.448 11 4 11.448 4 12C4 12.552 4.448 13 5 13H11V19C11 19.552 11.448 20 12 20C12.552 20 13 19.552 13 19V13H19C19.552 13 20 12.552 20 12C20 11.448 19.552 11 19 11Z"
fill="currentColor"
/>
</svg>
</div>
<span
class="font_button _display-inline _boxSizing-border-box _whiteSpace-pre-wrap _mt-0px _mr-0px _mb-0px _ml-0px _color-neutral1 _fontFamily-f-family _wordWrap-break-word _pt-t-space-spa94665593 _pb-t-space-spa94665593 _fontSize-f-size-medi3736 _fontWeight-f-weight-me3083741 _lineHeight-f-lineHeigh507465454"
data-disable-theme="true"
>
Add wallet
</span>
</div>
</div>
</div>
</div>
</div>
</span>
</div>
<div
style="display: contents;"
/>
</span>
</div>
<span
style="z-index: 1060; position: fixed; inset: 0; contain: strict; pointer-events: none;"
>
<span
class=" "
style="display: contents;"
>
<span
class="t_sub_theme t_light is_Theme"
style="display: contents;"
/>
</span>
</span>
<span
style="z-index: 1060; position: fixed; inset: 0; contain: strict; pointer-events: none;"
>
<span
class=" "
style="display: contents;"
>
<span
class="t_sub_theme t_light is_Theme"
style="display: contents;"
/>
</span>
</span>
<span
style="z-index: 1060; position: fixed; inset: 0; contain: strict; pointer-events: none;"
>
<span
class=" "
style="display: contents;"
>
<span
class="t_sub_theme t_light is_Theme"
style="display: contents;"
/>
</span>
</span>
</body>,
"container": <div>
<span
class=""
style="display: contents;"
>
<span
class="font_unset _display-inline _boxSizing-border-box _wordWrap-break-word _whiteSpace-pre-wrap _position-absolute _width-1px _height-1px _mt--1px _mr--1px _mb--1px _ml--1px _zIndex--10000 _overflowX-hidden _overflowY-hidden _opacity-1e-8 _pointerEvents-none"
>
<h2
class="is_DialogTitle font_heading _display-inline _boxSizing-border-box _wordWrap-break-word _userSelect-auto _color-color _whiteSpace-normal _fontFamily-f-family _mt-0px _mr-0px _mb-0px _ml-0px"
data-disable-theme="true"
id="Dialog--:r5:-title"
role="heading"
/>
</span>
<span
class="font_unset _display-inline _boxSizing-border-box _wordWrap-break-word _whiteSpace-pre-wrap _position-absolute _width-1px _height-1px _mt--1px _mr--1px _mb--1px _ml--1px _zIndex--10000 _overflowX-hidden _overflowY-hidden _opacity-1e-8 _pointerEvents-none"
>
<h2
class="is_DialogTitle font_heading _display-inline _boxSizing-border-box _wordWrap-break-word _userSelect-auto _color-color _whiteSpace-normal _fontFamily-f-family _mt-0px _mr-0px _mb-0px _ml-0px"
data-disable-theme="true"
id="Dialog--:rg:-title"
role="heading"
/>
</span>
<span
class="font_unset _display-inline _boxSizing-border-box _wordWrap-break-word _whiteSpace-pre-wrap _position-absolute _width-1px _height-1px _mt--1px _mr--1px _mb--1px _ml--1px _zIndex--10000 _overflowX-hidden _overflowY-hidden _opacity-1e-8 _pointerEvents-none"
>
<h2
class="is_DialogTitle font_heading _display-inline _boxSizing-border-box _wordWrap-break-word _userSelect-auto _color-color _whiteSpace-normal _fontFamily-f-family _mt-0px _mr-0px _mb-0px _ml-0px"
data-disable-theme="true"
id="Dialog--:rq:-title"
role="heading"
/>
</span>
<div
class="_display-flex _alignItems-stretch _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0 _flexDirection-column _backgroundColor-surface1 _pr-t-space-spa1360334080 _pl-t-space-spa1360334080 _pt-t-space-spa94665593 _pb-t-space-spa94665593"
>
<div
class="_display-flex _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0 _flexDirection-row _alignItems-center _pr-t-space-spa94665593 _pl-t-space-spa94665593 _pt-t-space-spa94665589 _pb-t-space-spa94665589 _width-10037"
>
<div
class="is_TouchableArea t_group_true"
role="button"
style="display: flex; align-items: stretch; flex-basis: auto; box-sizing: border-box; position: relative; min-height: 0px; min-width: 0px; flex-shrink: 0; flex-direction: column; border-top-left-radius: var(--t-radius-rounded12); border-top-right-radius: var(--t-radius-rounded12); border-bottom-right-radius: var(--t-radius-rounded12); border-bottom-left-radius: var(--t-radius-rounded12); cursor: pointer; outline-color: rgba(0, 0, 0, 0); opacity: 1; transform: scale(1);"
tabindex="0"
>
<svg
fill="none"
stroke-width="8"
style="width: 24px; height: 24px; color: rgba(19, 19, 19, 0.63);"
viewBox="0 0 16 16"
>
<path
d="M12.5303 4.53033C12.8232 4.23744 12.8232 3.76256 12.5303 3.46967C12.2374 3.17678 11.7626 3.17678 11.4697 3.46967L12.5303 4.53033ZM3.46967 11.4697C3.17678 11.7626 3.17678 12.2374 3.46967 12.5303C3.76256 12.8232 4.23744 12.8232 4.53033 12.5303L3.46967 11.4697ZM4.53033 3.46967C4.23744 3.17678 3.76256 3.17678 3.46967 3.46967C3.17678 3.76256 3.17678 4.23744 3.46967 4.53033L4.53033 3.46967ZM11.4697 12.5303C11.7626 12.8232 12.2374 12.8232 12.5303 12.5303C12.8232 12.2374 12.8232 11.7626 12.5303 11.4697L11.4697 12.5303ZM11.4697 3.46967L3.46967 11.4697L4.53033 12.5303L12.5303 4.53033L11.4697 3.46967ZM3.46967 4.53033L11.4697 12.5303L12.5303 11.4697L4.53033 3.46967L3.46967 4.53033Z"
fill="currentColor"
/>
</svg>
</div>
<div
class="_display-flex _alignItems-center _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-1 _flexDirection-column _justifyContent-center _flexGrow-1 _mr-t-space-non101 _pt-t-space-spa94665593 _pb-t-space-spa94665593"
>
<span
class="font_subHeading _display-inline _boxSizing-border-box _whiteSpace-pre-wrap _mt-0px _mr-0px _mb-0px _ml-0px _color-neutral1 _fontFamily-f-family _wordWrap-break-word _fontSize-f-size-larg101 _fontWeight-f-weight-bo3548 _lineHeight-f-lineHeigh1262266530"
data-disable-theme="true"
>
</span>
</div>
<div
class="_display-flex _alignItems-stretch _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0 _flexDirection-column"
>
<div
aria-expanded="false"
aria-haspopup="dialog"
class="_display-flex _alignItems-stretch _flexDirection-column _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0"
>
<div
aria-expanded="false"
class="_display-flex _alignItems-stretch _flexDirection-column _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0"
data-state="closed"
>
<div>
<div
class="is_TouchableArea t_group_true"
role="button"
style="display: flex; align-items: stretch; flex-basis: auto; box-sizing: border-box; position: relative; min-height: 0px; min-width: 0px; flex-shrink: 0; flex-direction: column; cursor: pointer; outline-color: rgba(0, 0, 0, 0); opacity: 1; border-top-left-radius: var(--t-radius-roundedFull); border-top-right-radius: var(--t-radius-roundedFull); border-bottom-right-radius: var(--t-radius-roundedFull); border-bottom-left-radius: var(--t-radius-roundedFull); transform: scale(1);"
tabindex="0"
>
<svg
fill="none"
stroke-width="8"
style="width: 20px; height: 20px; color: rgba(19, 19, 19, 0.63);"
viewBox="0 0 18 4"
>
<path
d="M9 3C9.55228 3 10 2.55228 10 2C10 1.44772 9.55228 1 9 1C8.44772 1 8 1.44772 8 2C8 2.55228 8.44772 3 9 3Z"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
<path
d="M16 3C16.5523 3 17 2.55228 17 2C17 1.44772 16.5523 1 16 1C15.4477 1 15 1.44772 15 2C15 2.55228 15.4477 3 16 3Z"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
<path
d="M2 3C2.55228 3 3 2.55228 3 2C3 1.44772 2.55228 1 2 1C1.44772 1 1 1.44772 1 2C1 2.55228 1.44772 3 2 3Z"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
</div>
</div>
</div>
</div>
</div>
</div>
<div
class="_display-flex _alignItems-stretch _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0 _flexDirection-column _pb-t-space-spa94665589 _pt-t-space-spa94665593 _pr-t-space-spa1360334080 _pl-t-space-spa1360334080"
>
<div
class="_display-flex _alignItems-stretch _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0 _flexDirection-row _alignSelf-stretch _width-10037 _justifyContent-center"
>
<div
class="_display-flex _alignItems-stretch _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-1 _flexDirection-column _flexGrow-1 _justifyContent-center"
>
<div
class="_display-flex _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-1 _flexDirection-column _gap-t-space-spa94665593 _alignItems-center"
>
<div
class="_display-flex _alignItems-center _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0 _flexDirection-column _justifyContent-center"
>
<div
class="_display-flex _alignItems-stretch _flexBasis-auto _boxSizing-border-box _minHeight-0px _minWidth-0px _flexShrink-0 _flexDirection-column _backgroundColor-transparent _borderTopColor-transparent _borderRightColor-transparent _borderBottomColor-transparent _borderLeftColor-transparent _borderTopLeftRadius-t-radius-ro1041013639 _borderTopRightRadius-t-radius-ro1041013639 _borderBottomRightRadius-t-radius-ro1041013639 _borderBottomLeftRadius-t-radius-ro1041013639 _borderTopWidth-t-space-non101 _borderRightWidth-t-space-non101 _borderBottomWidth-t-space-non101 _borderLeftWidth-t-space-non101 _position-relative _borderBottomStyle-solid _borderTopStyle-solid _borderLeftStyle-solid _borderRightStyle-solid"
data-testid="account-icon"
>
<div
class="t_unmounted t_unmounted"
style="display: flex; align-items: stretch; flex-basis: auto; box-sizing: border-box; position: relative; min-height: 0px; min-width: 0px; flex-shrink: 0; flex-direction: column; opacity: 0; transition: all 100ms cubic-bezier(0.17, 0.67, 0.45, 1);"
>
<svg
height="56"
viewBox="0 0 56 56"
width="56"
xmlns="http://www.w3.org/2000/svg"
>
<g
style="transform-origin: center center;"
>
<circle
cx="28"
cy="28"
fill="#FFBF171F"
r="28"
/>
<g
transform="translate(9.333333333333332, 9.333333333333332) scale(0.7777777777777778)"
/>
</g>
</svg>
</div>
</div>
</div>
<div
class="_display-flex _alignItems-stretch _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-1 _flexDirection-column _gap-t-space-non101"
>
<div
class="_display-flex _alignItems-stretch _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0 _flexDirection-row _gap-t-space-spa1360334080"
>
<div
class="_display-flex _alignItems-center _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexDirection-row _flexGrow-1 _gap-t-space-spa94665589 _flexShrink-1 _justifyContent-center"
>
<span
class="font_heading _display-inline _boxSizing-border-box _mt-0px _mr-0px _mb-0px _ml-0px _color-neutral1 _wordWrap-break-word _fontFamily-f-family _fontSize-18px _fontWeight-f-weight-bo3548 _lineHeight-24px _maxWidth-10037 _overflowX-hidden _overflowY-hidden _textOverflow-ellipsis _whiteSpace-initial _textAlign-center _flexShrink-1"
data-disable-theme="true"
data-testid="address-display/name/undefined"
/>
</div>
</div>
<div
class="_display-flex _alignItems-center _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0 _flexDirection-column _justifyContent-center"
>
<div
class="is_TouchableArea t_group_true"
data-testid="copy"
role="button"
style="display: flex; align-items: stretch; flex-basis: auto; box-sizing: border-box; position: relative; min-height: 0px; min-width: 0px; flex-shrink: 0; flex-direction: column; border-top-left-radius: var(--t-radius-rounded12); border-top-right-radius: var(--t-radius-rounded12); border-bottom-right-radius: var(--t-radius-rounded12); border-bottom-left-radius: var(--t-radius-rounded12); cursor: pointer; outline-color: rgba(0, 0, 0, 0); opacity: 1; transform: scale(1);"
tabindex="0"
>
<div
class="_color-_grouptrue-hover_accent3Hove112785 _backgroundColor-_grouptrue-hover_transparent _display-flex _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0 _flexDirection-row _alignItems-center _justifyContent-center _backgroundColor-transparent _borderTopLeftRadius-t-radius-ro1041013639 _borderTopRightRadius-t-radius-ro1041013639 _borderBottomRightRadius-t-radius-ro1041013639 _borderBottomLeftRadius-t-radius-ro1041013639 _gap-t-space-spa94665589 _mt-t-space-non101 _pr-t-space-non101 _pl-t-space-non101 _pt-t-space-non101 _pb-t-space-non101 _color-accent3"
>
<span
class="font_body _display-inline _boxSizing-border-box _whiteSpace-pre-wrap _mt-0px _mr-0px _mb-0px _ml-0px _color-neutral2 _fontFamily-f-family _wordWrap-break-word _fontSize-f-size-smal108 _fontWeight-f-weight-bo3548 _lineHeight-f-lineHeigh1269072494"
data-disable-theme="true"
>
0x9EB67f...D9A2Ca
</span>
<div
class="_display-flex _alignItems-stretch _flexBasis-auto _boxSizing-border-box _minHeight-0px _minWidth-0px _flexShrink-0 _flexDirection-column _position-relative _width-14px _height-14px"
>
<div
class="_display-flex _alignItems-stretch _flexBasis-auto _boxSizing-border-box _minHeight-0px _minWidth-0px _flexShrink-0 _flexDirection-column _position-absolute _top-0px _left-0px"
>
<div
style="display: flex; align-items: stretch; flex-basis: auto; box-sizing: border-box; position: relative; min-height: 0px; min-width: 0px; flex-shrink: 0; flex-direction: column; opacity: 1; width: 100%; flex-grow: 1; transform: translateX(0px); transition: all 300ms ease-in-out;"
>
<svg
fill="none"
stroke-width="8"
style="width: 14px; height: 14px; color: rgba(19, 19, 19, 0.63);"
viewBox="0 0 24 24"
>
<path
d="M18.375 6.25H9.625C7.448 6.25 6.25 7.448 6.25 9.625V18.375C6.25 20.552 7.448 21.75 9.625 21.75H18.375C20.552 21.75 21.75 20.552 21.75 18.375V9.625C21.75 7.448 20.552 6.25 18.375 6.25ZM20.25 18.375C20.25 19.707 19.707 20.25 18.375 20.25H9.625C8.293 20.25 7.75 19.707 7.75 18.375V9.625C7.75 8.293 8.293 7.75 9.625 7.75H18.375C19.707 7.75 20.25 8.293 20.25 9.625V18.375ZM3.75 5.62V14.38C3.75 15.578 4.23309 15.873 4.39209 15.971C4.74609 16.187 4.85589 16.649 4.63989 17.002C4.49789 17.233 4.25202 17.36 3.99902 17.36C3.86602 17.36 3.72991 17.324 3.60791 17.25C2.70691 16.698 2.25 15.733 2.25 14.38V5.62C2.25 3.478 3.47912 2.25 5.62012 2.25H14.3799C16.0649 2.25 16.87 2.98897 17.25 3.60797C17.466 3.96097 17.355 4.42298 17.002 4.63898C16.648 4.85598 16.1879 4.74399 15.9709 4.39099C15.8739 4.23199 15.5779 3.74902 14.3799 3.74902H5.62012C4.29212 3.75002 3.75 4.292 3.75 5.62Z"
fill="currentColor"
/>
</svg>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div
class="_display-flex _alignItems-stretch _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0 _flexDirection-column _pt-t-space-pad1331704925"
>
<div
class="_display-flex _alignItems-stretch _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0 _flexDirection-row"
>
<button
class="is_Button t_group_item"
data-testid="account-card"
dd-action-name="Edit label"
style="display: flex; flex-basis: 0px; box-sizing: border-box; position: relative; min-height: 0px; min-width: 0px; flex-shrink: 1; flex-direction: row; border-top-width: 1px; border-right-width: 1px; border-bottom-width: 1px; border-left-width: 1px; align-items: center; justify-content: center; cursor: pointer; height: auto; align-self: stretch; flex-grow: 1; outline-color: rgba(0, 0, 0, 0); border-top-left-radius: var(--t-radius-rounded12); border-top-right-radius: var(--t-radius-rounded12); border-bottom-right-radius: var(--t-radius-rounded12); border-bottom-left-radius: var(--t-radius-rounded12); gap: var(--t-space-spacing8); border-bottom-style: solid; border-top-style: solid; border-left-style: solid; border-right-style: solid; transform: scale(1); transition: transform 100ms cubic-bezier(0.17, 0.67, 0.45, 1);"
>
<span
class="font_button _color-_groupitem-hover_neutral1Hov3121676 _display-inline _boxSizing-border-box _wordWrap-break-word _whiteSpace-nowrap _mt-0px _mr-0px _mb-0px _ml-0px _color-neutral1 _fontFamily-f-family _maxWidth-10037 _overflowX-hidden _overflowY-hidden _textOverflow-ellipsis _textAlign-center _fontSize-f-size-smal108 _fontWeight-f-weight-me3083741 _lineHeight-f-lineHeigh1269072494"
line-height-disabled="false"
>
Edit label
</span>
</button>
</div>
</div>
</div>
<span
class="_dsp_contents"
>
<div
class="css-view-175oi2r r-WebkitOverflowScrolling-150rngu r-flexDirection-eqz5dr r-flexGrow-16y2uox r-flexShrink-1wbh5a2 r-overflowX-11yh6sk r-overflowY-1rnoaur r-transform-agouwx is_ScrollView _backgroundColor-surface1 _height-auto"
>
<div
class="css-view-175oi2r"
>
<div
class="_display-flex _alignItems-stretch _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0 _flexDirection-column _pt-t-space-pad1331704900"
/>
<div
aria-expanded="false"
aria-haspopup="dialog"
class="_display-flex _alignItems-stretch _flexDirection-column _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0"
>
<div
aria-expanded="false"
class="_display-flex _alignItems-stretch _flexDirection-column _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0"
data-state="closed"
>
<div
class="_display-flex _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0 _flexDirection-row _alignItems-center _cursor-pointer _gap-t-space-spa1360334080 _mt-t-space-spa1360334080 _pb-t-space-spa94665589 _pr-t-space-spa1360334080 _pl-t-space-spa1360334080"
>
<div
class="_display-flex _alignItems-center _flexBasis-auto _boxSizing-border-box _position-relative _minHeight-0px _minWidth-0px _flexShrink-0 _flexDirection-column _justifyContent-center _backgroundColor-surface1 _borderTopColor-surface3 _borderRightColor-surface3 _borderBottomColor-surface3 _borderLeftColor-surface3 _borderTopLeftRadius-t-radius-ro1041013639 _borderTopRightRadius-t-radius-ro1041013639 _borderBottomRightRadius-t-radius-ro1041013639 _borderBottomLeftRadius-t-radius-ro1041013639 _borderTopWidth-t-space-spa94665586 _borderRightWidth-t-space-spa94665586 _borderBottomWidth-t-space-spa94665586 _borderLeftWidth-t-space-spa94665586 _height-32px _pt-t-space-spa94665593 _pr-t-space-spa94665593 _pb-t-space-spa94665593 _pl-t-space-spa94665593 _width-32px _borderBottomStyle-solid _borderTopStyle-solid _borderLeftStyle-solid _borderRightStyle-solid _boxShadow-0px0px10pxv169431092"
>
<svg
fill="none"
stroke-width="2"
style="width: 16px; height: 16px; color: rgba(19, 19, 19, 0.63);"
viewBox="0 0 24 24"
>
<path
d="M19 11H13V5C13 4.448 12.552 4 12 4C11.448 4 11 4.448 11 5V11H5C4.448 11 4 11.448 4 12C4 12.552 4.448 13 5 13H11V19C11 19.552 11.448 20 12 20C12.552 20 13 19.552 13 19V13H19C19.552 13 20 12.552 20 12C20 11.448 19.552 11 19 11Z"
fill="currentColor"
/>
</svg>
</div>
<span
class="font_button _display-inline _boxSizing-border-box _whiteSpace-pre-wrap _mt-0px _mr-0px _mb-0px _ml-0px _color-neutral1 _fontFamily-f-family _wordWrap-break-word _pt-t-space-spa94665593 _pb-t-space-spa94665593 _fontSize-f-size-medi3736 _fontWeight-f-weight-me3083741 _lineHeight-f-lineHeigh507465454"
data-disable-theme="true"
>
Add wallet
</span>
</div>
</div>
</div>
</div>
</div>
</span>
</div>
<div
style="display: contents;"
/>
</span>
</div>,
"debug": [Function],
"findAllByAltText": [Function],
"findAllByDisplayValue": [Function],
"findAllByLabelText": [Function],
"findAllByPlaceholderText": [Function],
"findAllByRole": [Function],
"findAllByTestId": [Function],
"findAllByText": [Function],
"findAllByTitle": [Function],
"findByAltText": [Function],
"findByDisplayValue": [Function],
"findByLabelText": [Function],
"findByPlaceholderText": [Function],
"findByRole": [Function],
"findByTestId": [Function],
"findByText": [Function],
"findByTitle": [Function],
"getAllByAltText": [Function],
"getAllByDisplayValue": [Function],
"getAllByLabelText": [Function],
"getAllByPlaceholderText": [Function],
"getAllByRole": [Function],
"getAllByTestId": [Function],
"getAllByText": [Function],
"getAllByTitle": [Function],
"getByAltText": [Function],
"getByDisplayValue": [Function],
"getByLabelText": [Function],
"getByPlaceholderText": [Function],
"getByRole": [Function],
"getByTestId": [Function],
"getByText": [Function],
"getByTitle": [Function],
"queryAllByAltText": [Function],
"queryAllByDisplayValue": [Function],
"queryAllByLabelText": [Function],
"queryAllByPlaceholderText": [Function],
"queryAllByRole": [Function],
"queryAllByTestId": [Function],
"queryAllByText": [Function],
"queryAllByTitle": [Function],
"queryByAltText": [Function],
"queryByDisplayValue": [Function],
"queryByLabelText": [Function],
"queryByPlaceholderText": [Function],
"queryByRole": [Function],
"queryByTestId": [Function],
"queryByText": [Function],
"queryByTitle": [Function],
"rerender": [Function],
"store": {
"dispatch": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
Symbol(observable): [Function],
},
"unmount": [Function],
}
`;
@@ -0,0 +1,127 @@
import { useSortedAccountList } from 'src/app/features/accounts/useSortedAccountList'
import { act, renderHook } from 'src/test/test-utils'
import { useAccountListData } from 'wallet/src/features/accounts/useAccountListData'
jest.mock('wallet/src/features/accounts/useAccountListData')
const mockUseAccountList = useAccountListData as jest.MockedFunction<typeof useAccountListData>
describe('useSortedAccountList', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('should sort addresses by balance in descending order', () => {
mockAccountList([mockPortfolio('address1', 100), mockPortfolio('address2', 200), mockPortfolio('address3', 150)])
const addresses = ['address1', 'address2', 'address3']
const { result } = renderHook(() => useSortedAccountList(addresses))
expect(result.current).toEqual([
{ address: 'address2', balance: 200 },
{ address: 'address3', balance: 150 },
{ address: 'address1', balance: 100 },
])
})
it('should handle undefined portfolios', () => {
mockAccountList(undefined)
const addresses = ['address1', 'address2']
const { result } = renderHook(() => useSortedAccountList(addresses))
expect(result.current).toEqual([
{ address: 'address1', balance: 0 },
{ address: 'address2', balance: 0 },
])
})
it('should use previous data during balance updates', () => {
mockAccountList([mockPortfolio('address1', 100), mockPortfolio('address2', 200)])
const addresses = ['address1', 'address2']
const { result, rerender } = renderHook((props) => useSortedAccountList(props), { initialProps: addresses })
expect(result.current).toEqual([
{ address: 'address2', balance: 200 },
{ address: 'address1', balance: 100 },
])
mockAccountList([mockPortfolio('address1', 100)], true)
rerender(['address1'])
expect(result.current).toEqual([{ address: 'address1', balance: 100 }])
})
it('should keep list order when an account is removed', async () => {
mockAccountList([mockPortfolio('address1', 100), mockPortfolio('address2', 200), mockPortfolio('address3', 300)])
const addresses = ['address1', 'address2', 'address3']
const { result, rerender } = renderHook((props) => useSortedAccountList(props), { initialProps: addresses })
expect(result.current).toEqual([
{ address: 'address3', balance: 300 },
{ address: 'address2', balance: 200 },
{ address: 'address1', balance: 100 },
])
mockAccountListUndefined()
await act(async () => {
rerender(['address1', 'address2'])
await new Promise((resolve) => setTimeout(resolve, 100))
})
expect(result.current).toEqual([
{ address: 'address2', balance: 200 },
{ address: 'address1', balance: 100 },
])
mockAccountList([mockPortfolio('address1', 100), mockPortfolio('address2', 200)])
await act(async () => {
rerender(['address1', 'address2'])
})
expect(result.current).toEqual([
{ address: 'address2', balance: 200 },
{ address: 'address1', balance: 100 },
])
})
})
function mockPortfolio(
ownerAddress: Address,
balance: number,
): {
id: string
ownerAddress: Address
tokensTotalDenominatedValue: { __typename?: 'Amount'; value: number }
} {
return {
id: ownerAddress,
ownerAddress,
tokensTotalDenominatedValue: { __typename: 'Amount', value: balance },
}
}
function mockAccountList(portfolios: ReturnType<typeof mockPortfolio>[] | undefined, loading = false): void {
mockUseAccountList.mockReturnValue({
data: { portfolios },
loading,
networkStatus: 7,
refetch: jest.fn(),
startPolling: jest.fn(),
stopPolling: jest.fn(),
})
}
function mockAccountListUndefined(): void {
mockUseAccountList.mockReturnValue({
data: undefined,
loading: true,
networkStatus: 7,
refetch: jest.fn(),
startPolling: jest.fn(),
stopPolling: jest.fn(),
})
}
@@ -0,0 +1,47 @@
import { useMemo } from 'react'
import { usePrevious } from 'utilities/src/react/hooks'
import { useAccountListData } from 'wallet/src/features/accounts/useAccountListData'
interface AddressWithBalance {
address: Address
balance: number
}
export function useSortedAccountList(addresses: Address[]): AddressWithBalance[] {
const { data: accountBalanceData } = useAccountListData({
addresses,
})
/*
Why are we using previousAccountBalanceData?
This is a workaround for a data fetching inefficiency. When removing an address, we send a new query
with the updated address array, causing Apollo to refetch ALL balances. During this refetch, balances
temporarily show as 0, causing the list to re-sort momentarily.
We use previousAccountBalanceData to maintain the last known good balances during this refetch. The balances
will be updated once the new query completes.
*/
const previousAccountBalanceData = usePrevious(accountBalanceData)
const balanceRecord: Record<Address, number> = useMemo(() => {
const data = accountBalanceData || previousAccountBalanceData
if (!data?.portfolios) {
return {}
}
return Object.fromEntries(
data.portfolios
.filter((portfolio): portfolio is NonNullable<typeof portfolio> => Boolean(portfolio))
.map((portfolio) => [portfolio.ownerAddress, portfolio.tokensTotalDenominatedValue?.value ?? 0]),
)
}, [accountBalanceData, previousAccountBalanceData])
return useMemo(() => {
return addresses
.map((address) => ({
address,
balance: balanceRecord[address] ?? 0,
}))
.sort((a, b) => b.balance - a.balance)
}, [addresses, balanceRecord])
}
@@ -0,0 +1,144 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useDispatch, useSelector } from 'react-redux'
import { Button, Flex, Text, TouchableArea } from 'ui/src'
import { Feedback, LikeSquare, MessageText, X } from 'ui/src/components/icons'
import { IconSizeTokens, zIndexes } from 'ui/src/theme'
import { Modal } from 'uniswap/src/components/modals/Modal'
import { uniswapUrls } from 'uniswap/src/constants/urls'
import { ModalName, WalletEventName } from 'uniswap/src/features/telemetry/constants'
import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send'
import { appRatingPromptedMsSelector, appRatingProvidedMsSelector } from 'wallet/src/features/wallet/selectors'
import { setAppRating } from 'wallet/src/features/wallet/slice'
interface AppRatingModalProps {
onClose: () => void
}
enum State {
Initial = 0,
NotReally = 1,
Yes = 2,
}
export default function AppRatingModal({ onClose }: AppRatingModalProps): JSX.Element | null {
const { t } = useTranslation()
const [state, setState] = useState(State.Initial)
const dispatch = useDispatch()
const appRatingPromptedMs = useSelector(appRatingPromptedMsSelector)
const appRatingProvidedMs = useSelector(appRatingProvidedMsSelector)
const close = (): void => {
sendAnalyticsEvent(WalletEventName.AppRating, {
type: 'close',
appRatingPromptedMs,
appRatingProvidedMs,
})
onClose()
}
const onRemindLater = (): void => {
sendAnalyticsEvent(WalletEventName.AppRating, {
type: 'remind',
appRatingPromptedMs,
appRatingProvidedMs,
})
onClose()
}
const stateConfig = {
[State.Initial]: {
title: t('appRating.extension.title'),
description: t('appRating.description'),
secondaryButtonText: t('appRating.button.notReally'),
primaryButtonText: t('common.button.yes'),
Icon: LikeSquare,
iconSize: '$icon.24' as IconSizeTokens,
onSecondaryButtonPress: () => setState(State.NotReally),
onPrimaryButtonPress: () => setState(State.Yes),
},
[State.NotReally]: {
title: t('appRating.feedback.title'),
description: t('appRating.feedback.description'),
secondaryButtonText: t('common.button.notNow'),
primaryButtonText: t('appRating.feedback.button.send'),
Icon: MessageText,
iconSize: '$icon.18' as IconSizeTokens,
onSecondaryButtonPress: onRemindLater,
onPrimaryButtonPress: (): void => {
window.open(uniswapUrls.walletFeedbackForm)
dispatch(setAppRating({ feedbackProvided: true }))
sendAnalyticsEvent(WalletEventName.AppRating, {
type: 'feedback-form',
appRatingPromptedMs,
appRatingProvidedMs,
})
onClose()
},
},
[State.Yes]: {
title: t('appRating.extension.review.title'),
description: t('appRating.extension.review.description'),
secondaryButtonText: t('common.button.notNow'),
primaryButtonText: t('common.button.review'),
Icon: Feedback,
iconSize: '$icon.24' as IconSizeTokens,
onSecondaryButtonPress: onRemindLater,
onPrimaryButtonPress: (): void => {
window.open(`https://chromewebstore.google.com/detail/uniswap-extension/${chrome.runtime.id}/reviews`)
dispatch(setAppRating({ ratingProvided: true }))
sendAnalyticsEvent(WalletEventName.AppRating, {
type: 'store-review',
appRatingPromptedMs,
appRatingProvidedMs: Date.now(), // to avoid race condition with updates from redux
})
onClose()
},
},
}
const {
title,
description,
secondaryButtonText,
primaryButtonText,
Icon,
iconSize,
onSecondaryButtonPress,
onPrimaryButtonPress,
} = stateConfig[state]
useEffect(() => {
// just to set that prompt has been shown
dispatch(setAppRating({}))
}, [dispatch])
return (
<Modal isDismissible isModalOpen name={ModalName.AppRatingModal} backgroundColor="$surface1" onClose={close}>
<TouchableArea p="$spacing16" position="absolute" right={0} top={0} zIndex={zIndexes.default} onPress={close}>
<X color="$neutral2" size="$icon.20" />
</TouchableArea>
<Flex alignItems="center" gap="$spacing8" pt="$spacing16">
<Flex centered backgroundColor="$accent2" width="$spacing48" height="$spacing48" borderRadius="$rounded12">
<Icon color="$accent1" size={iconSize} />
</Flex>
<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>
<Flex row width="100%" gap="$spacing12">
<Button flexBasis={1} size="small" emphasis="secondary" onPress={onSecondaryButtonPress}>
{secondaryButtonText}
</Button>
<Button flexBasis={1} size="small" variant="branded" onPress={onPrimaryButtonPress}>
{primaryButtonText}
</Button>
</Flex>
</Flex>
</Modal>
)
}
@@ -0,0 +1,44 @@
import { logger } from 'utilities/src/logger/logger'
import { SecretPayload } from 'wallet/src/features/wallet/Keyring/crypto'
import { PersistedStorage } from 'wallet/src/utils/persistedStorage'
export type BiometricUnlockStorageData = {
credentialId: string
transports: AuthenticatorTransport[]
secretPayload: Omit<SecretPayload, 'ciphertext'> & { ciphertext: string }
}
const BIOMETRIC_UNLOCK_STORAGE_KEY = 'biometricUnlock'
const storage = new PersistedStorage('local')
interface IBiometricUnlockStorage {
set(data: BiometricUnlockStorageData): Promise<void>
get(): Promise<BiometricUnlockStorageData | null>
remove(): Promise<void>
}
export const BiometricUnlockStorage: IBiometricUnlockStorage = {
set: async (data: BiometricUnlockStorageData) => {
await storage.setItem(BIOMETRIC_UNLOCK_STORAGE_KEY, JSON.stringify(data))
},
remove: async () => {
await storage.removeItem(BIOMETRIC_UNLOCK_STORAGE_KEY)
},
get: async () => {
const data = await storage.getItem(BIOMETRIC_UNLOCK_STORAGE_KEY)
try {
return data ? (JSON.parse(data) as BiometricUnlockStorageData) : null
} catch (error) {
logger.error(error, {
tags: {
file: 'BiometricUnlockStorage.ts',
function: 'BiometricUnlockStorage.get',
},
})
return null
}
},
}
@@ -0,0 +1,120 @@
import { BiometricUnlockStorageData } from 'src/app/features/biometricUnlock/BiometricUnlockStorage'
import { assertAuthenticatorAssertionResponse } from 'src/app/features/biometricUnlock/utils/assertAuthenticatorAssertionResponse'
import { assertPublicKeyCredential } from 'src/app/features/biometricUnlock/utils/assertPublicKeyCredential'
import { ONE_SECOND_MS } from 'utilities/src/time/time'
import {
addEncryptedCiphertextToSecretPayload,
convertBytesToCryptoKey,
createEmptySecretPayload,
decodeFromStorage,
decrypt,
generateNew256BitRandomBuffer,
} from 'wallet/src/features/wallet/Keyring/crypto'
/**
* Authenticates with a biometric credential and returns both the credential and encryption key
*/
export async function authenticateWithBiometricCredential({
credentialId,
transports,
abortSignal,
}: {
credentialId: string
transports: AuthenticatorTransport[]
abortSignal: AbortSignal
}): Promise<{ publicKeyCredential: PublicKeyCredential; encryptionKey: CryptoKey }> {
// Convert stored credential ID back to binary format
const credentialIdBuffer = Uint8Array.from(atob(credentialId), (c) => c.charCodeAt(0))
const credential = await navigator.credentials.get({
publicKey: {
challenge: generateNew256BitRandomBuffer(),
allowCredentials: [
{
type: 'public-key',
id: credentialIdBuffer,
transports,
},
],
userVerification: 'required',
timeout: 15 * ONE_SECOND_MS,
},
signal: abortSignal,
})
const publicKeyCredential = assertPublicKeyCredential(credential)
const encryptionKey = await extractEncryptionKeyFromCredential(publicKeyCredential)
return { publicKeyCredential, encryptionKey }
}
/**
* Extracts the encryption key from a WebAuthn credential response
*/
async function extractEncryptionKeyFromCredential(publicKeyCredential: PublicKeyCredential): Promise<CryptoKey> {
const response = publicKeyCredential.response
assertAuthenticatorAssertionResponse(response)
if (!response.userHandle) {
throw new Error('No user handle returned from biometric authentication')
}
// The user handle contains our encryption key (stored during credential creation)
const encryptionKeyBuffer = new Uint8Array(response.userHandle)
const cryptoKey = await convertBytesToCryptoKey(encryptionKeyBuffer)
return cryptoKey
}
/**
* Encrypts a password with biometric data using the provided encryption key
*/
export async function encryptPasswordWithBiometricData({
password,
encryptionKey,
credentialId,
transports,
}: {
password: string
encryptionKey: CryptoKey
credentialId: string
transports: AuthenticatorTransport[]
}): Promise<BiometricUnlockStorageData> {
// Create a new secret payload for the password
const secretPayload = await createEmptySecretPayload()
// Encrypt the password with the encryption key
const secretPayloadWithCiphertext = await addEncryptedCiphertextToSecretPayload({
secretPayload,
plaintext: password,
encryptionKey,
additionalData: credentialId, // Use credential ID as additional authenticated data
})
return { credentialId, transports, secretPayload: secretPayloadWithCiphertext }
}
/**
* Decrypts a password from biometric credential data
*/
export async function decryptPasswordFromBiometricData({
encryptionKey,
biometricUnlockCredential,
}: {
encryptionKey: CryptoKey
biometricUnlockCredential: BiometricUnlockStorageData
}): Promise<string> {
// Decrypt the password
const decryptedPassword = await decrypt({
encryptionKey,
ciphertext: decodeFromStorage(biometricUnlockCredential.secretPayload.ciphertext),
iv: decodeFromStorage(biometricUnlockCredential.secretPayload.iv),
additionalData: biometricUnlockCredential.credentialId, // Use credential ID as additional authenticated data
})
if (!decryptedPassword) {
throw new Error('Failed to decrypt password')
}
return decryptedPassword
}
@@ -0,0 +1,21 @@
import {
BiometricUnlockStorage,
BiometricUnlockStorageData,
} from 'src/app/features/biometricUnlock/BiometricUnlockStorage'
import { ReactQueryCacheKey } from 'utilities/src/reactQuery/cache'
import type { QueryOptionsResult } from 'utilities/src/reactQuery/queryOptions'
import { queryWithoutCache } from 'utilities/src/reactQuery/queryOptions'
type BiometricUnlockCredentialQueryOptions = QueryOptionsResult<
Awaited<BiometricUnlockStorageData | null>,
Error,
Awaited<BiometricUnlockStorageData | null>,
[ReactQueryCacheKey.ExtensionBiometricUnlockCredential]
>
export function biometricUnlockCredentialQuery(): BiometricUnlockCredentialQueryOptions {
return queryWithoutCache({
queryKey: [ReactQueryCacheKey.ExtensionBiometricUnlockCredential],
queryFn: async () => await BiometricUnlockStorage.get(),
})
}
@@ -0,0 +1,25 @@
import { UseMutationResult, useMutation, useQueryClient } from '@tanstack/react-query'
import { biometricUnlockCredentialQuery } from 'src/app/features/biometricUnlock/biometricUnlockCredentialQuery'
import { BiometricUnlockStorage } from 'src/app/features/biometricUnlock/BiometricUnlockStorage'
import { logger } from 'utilities/src/logger/logger'
export function useBiometricUnlockDisableMutation(): UseMutationResult<void, Error, void> {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async () => {
await BiometricUnlockStorage.remove()
},
onSettled: () => {
queryClient.invalidateQueries(biometricUnlockCredentialQuery())
},
onError: (error) => {
logger.error(error, {
tags: {
file: 'useBiometricUnlockDisableMutation.ts',
function: 'useBiometricUnlockDisableMutation',
},
})
},
})
}
@@ -0,0 +1,254 @@
import { webcrypto } from 'node:crypto'
import { waitFor } from '@testing-library/react'
import { BiometricUnlockStorage } from 'src/app/features/biometricUnlock/BiometricUnlockStorage'
import { useBiometricUnlockSetupMutation } from 'src/app/features/biometricUnlock/useBiometricUnlockSetupMutation'
import { isUserVerifyingPlatformAuthenticatorAvailable } from 'src/app/utils/device/builtInBiometricCapabilitiesQuery'
import { renderHookWithProviders } from 'src/test/render'
import { decodeFromStorage, decrypt } from 'wallet/src/features/wallet/Keyring/crypto'
jest.mock('src/app/features/biometricUnlock/BiometricUnlockStorage')
jest.mock('src/app/utils/device/builtInBiometricCapabilitiesQuery')
jest.mock('wallet/src/features/wallet/Keyring/crypto', () => ({
...jest.requireActual('wallet/src/features/wallet/Keyring/crypto'),
createEmptySecretPayload: jest.fn(),
getEncryptionKeyFromBuffer: jest.fn(),
}))
// Mock the Web Crypto API with Node.js built-in
Object.defineProperty(globalThis, 'crypto', {
value: webcrypto,
})
// Mock the WebAuthn API
const mockCredentialsCreate = jest.fn()
Object.defineProperty(navigator, 'credentials', {
writable: true,
value: { create: mockCredentialsCreate },
})
const mockBiometricUnlockStorage = BiometricUnlockStorage as jest.Mocked<typeof BiometricUnlockStorage>
const mockIsUserVerifyingPlatformAuthenticatorAvailable =
isUserVerifyingPlatformAuthenticatorAvailable as jest.MockedFunction<
typeof isUserVerifyingPlatformAuthenticatorAvailable
>
// Mock crypto functions
const mockCreateEmptySecretPayload = jest.requireMock(
'wallet/src/features/wallet/Keyring/crypto',
).createEmptySecretPayload
const mockGetEncryptionKeyFromBuffer = jest.requireMock(
'wallet/src/features/wallet/Keyring/crypto',
).getEncryptionKeyFromBuffer
// Mock PublicKeyCredential (doesn't exist in Jest environment)
class MockPublicKeyCredential {
constructor(
public rawId: ArrayBuffer,
public response = {
getTransports: () => ['internal' as AuthenticatorTransport],
},
) {}
}
Object.defineProperty(global, 'PublicKeyCredential', {
writable: true,
value: MockPublicKeyCredential,
})
describe('useBiometricUnlockSetupMutation', () => {
const mockPassword = 'test-password-123'
const mockPublicKeyCredential = new MockPublicKeyCredential(new ArrayBuffer(16))
let mockEncryptionKey: CryptoKey
let mockSecretPayload: any
beforeEach(async () => {
// Create a real AES key that can be used with real crypto.subtle.exportKey
mockEncryptionKey = await globalThis.crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, [
'encrypt',
'decrypt',
])
mockSecretPayload = {
iv: '12,34,56,78,90,12,34,56,78,90,12,34,56,78,90,12', // Mock IV as comma-separated string (ArrayBuffer.toString() format)
salt: '11,22,33,44,55,66,77,88,99,00,11,22,33,44,55,66', // Mock salt as comma-separated string
name: 'PBKDF2',
iterations: 100000,
hash: 'SHA-256',
}
// Setup happy path defaults
mockIsUserVerifyingPlatformAuthenticatorAvailable.mockResolvedValue(true)
mockCreateEmptySecretPayload.mockResolvedValue(mockSecretPayload)
mockGetEncryptionKeyFromBuffer.mockResolvedValue(mockEncryptionKey)
mockCredentialsCreate.mockResolvedValue(mockPublicKeyCredential)
mockBiometricUnlockStorage.set.mockResolvedValue()
jest.clearAllMocks()
})
describe('successful setup', () => {
it('should successfully set up biometric unlock', async () => {
const { result } = renderHookWithProviders(() => useBiometricUnlockSetupMutation())
result.current.mutate(mockPassword)
await waitFor(() => {
expect(result.current.isSuccess).toBe(true)
})
const expectedCredentialId = btoa(String.fromCharCode(...new Uint8Array(mockPublicKeyCredential.rawId)))
const expectedRawKey = await globalThis.crypto.subtle.exportKey('raw', mockEncryptionKey)
// Should check if platform authenticator is available
expect(mockIsUserVerifyingPlatformAuthenticatorAvailable).toHaveBeenCalledTimes(1)
// Should create WebAuthn credential with proper security configuration
expect(mockCredentialsCreate).toHaveBeenCalledWith({
publicKey: expect.objectContaining({
rp: { name: 'Uniswap Extension', id: window.location.hostname },
user: expect.objectContaining({
id: expectedRawKey, // Encryption key used as user ID
name: 'Uniswap Extension',
displayName: 'Uniswap Extension',
}),
authenticatorSelection: {
authenticatorAttachment: 'platform', // Must use built-in authenticator
residentKey: 'required', // Credential stored on device
userVerification: 'required', // Biometric verification required
},
hints: ['client-device'],
pubKeyCredParams: expect.arrayContaining([
{ type: 'public-key', alg: -7 },
{ type: 'public-key', alg: -257 },
{ type: 'public-key', alg: -8 },
]),
}),
signal: expect.any(AbortSignal),
})
// Verify the stored secret payload has all required properties
const storedData = mockBiometricUnlockStorage.set.mock.calls[0]![0] as {
credentialId: string
transports: AuthenticatorTransport[]
secretPayload: typeof mockSecretPayload
}
expect(storedData.credentialId).toBe(expectedCredentialId)
expect(storedData.transports).toEqual(['internal'])
expect(storedData.secretPayload).toEqual(
expect.objectContaining({
iv: mockSecretPayload.iv,
salt: mockSecretPayload.salt,
name: mockSecretPayload.name,
iterations: mockSecretPayload.iterations,
hash: mockSecretPayload.hash,
ciphertext: expect.any(String),
}),
)
// Verify the encrypted password can be decrypted back to the original
const decryptedPassword = await decrypt({
encryptionKey: mockEncryptionKey,
ciphertext: decodeFromStorage(storedData.secretPayload.ciphertext),
iv: decodeFromStorage(storedData.secretPayload.iv),
additionalData: storedData.credentialId, // Same credential ID used for encryption
})
expect(decryptedPassword).toBe(mockPassword) // Should decrypt back to original password
})
})
describe('error handling', () => {
it('should throw error when platform authenticator is not available', async () => {
mockIsUserVerifyingPlatformAuthenticatorAvailable.mockResolvedValue(false)
const { result } = renderHookWithProviders(() => useBiometricUnlockSetupMutation())
result.current.mutate(mockPassword)
await waitFor(() => {
expect(result.current.isError).toBe(true)
})
expect(result.current.error?.message).toBe(
'Invalid call to setup biometric unlock when platform authenticator is not available',
)
})
it('should handle credential creation failure', async () => {
mockCredentialsCreate.mockResolvedValue(null)
const { result } = renderHookWithProviders(() => useBiometricUnlockSetupMutation())
result.current.mutate(mockPassword)
await waitFor(() => {
expect(result.current.isError).toBe(true)
})
expect(result.current.error?.message).toBe('Failed to create credential')
})
it('should handle non-PublicKeyCredential response', async () => {
mockCredentialsCreate.mockResolvedValue({} as Credential)
const { result } = renderHookWithProviders(() => useBiometricUnlockSetupMutation())
result.current.mutate(mockPassword)
await waitFor(() => {
expect(result.current.isError).toBe(true)
})
expect(result.current.error?.message).toBe('Created credential is not a `PublicKeyCredential`')
})
it('should handle crypto operations errors', async () => {
const cryptoError = new Error('Crypto operation failed')
mockCreateEmptySecretPayload.mockRejectedValue(cryptoError)
const { result } = renderHookWithProviders(() => useBiometricUnlockSetupMutation())
result.current.mutate(mockPassword)
await waitFor(() => {
expect(result.current.isError).toBe(true)
})
expect(result.current.error).toBe(cryptoError)
})
it('should handle storage errors', async () => {
const storageError = new Error('Storage failed')
mockBiometricUnlockStorage.set.mockRejectedValue(storageError)
const { result } = renderHookWithProviders(() => useBiometricUnlockSetupMutation())
result.current.mutate(mockPassword)
await waitFor(() => {
expect(result.current.isError).toBe(true)
})
expect(result.current.error).toBe(storageError)
})
})
describe('mutation configuration', () => {
it('should not retry on failure', async () => {
mockIsUserVerifyingPlatformAuthenticatorAvailable.mockResolvedValue(false)
const { result } = renderHookWithProviders(() => useBiometricUnlockSetupMutation())
result.current.mutate(mockPassword)
await waitFor(() => {
expect(result.current.isError).toBe(true)
})
// Should only be called once (no retries)
expect(mockIsUserVerifyingPlatformAuthenticatorAvailable).toHaveBeenCalledTimes(1)
})
})
})
@@ -0,0 +1,156 @@
import { UseMutationResult, useMutation, useQueryClient } from '@tanstack/react-query'
import { startNavigatorCredentialRequest } from 'src/app/features/biometricUnlock/useNavigatorCredentialAbortSignal'
import { assertPublicKeyCredential } from 'src/app/features/biometricUnlock/utils/assertPublicKeyCredential'
import { isUserVerifyingPlatformAuthenticatorAvailable } from 'src/app/utils/device/builtInBiometricCapabilitiesQuery'
import { logger } from 'utilities/src/logger/logger'
import { ONE_SECOND_MS } from 'utilities/src/time/time'
import {
createEmptySecretPayload,
generateNew256BitRandomBuffer,
getEncryptionKeyFromBuffer,
} from 'wallet/src/features/wallet/Keyring/crypto'
// Extend PublicKeyCredentialCreationOptions to include Chrome 128+ hints property
interface PublicKeyCredentialCreationOptionsWithHints extends PublicKeyCredentialCreationOptions {
hints?: string[]
}
export function useBiometricUnlockSetupMutation(options?: {
onSuccess?: () => void
onError?: (error: Error) => void
}): UseMutationResult<void, Error, string> {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (password: string) => {
const { abortSignal } = startNavigatorCredentialRequest('New biometric setup request initiated')
await assertIsUserVerifyingPlatformAuthenticatorAvailable()
const biometricStorageData = await createCredentialAndEncryptPassword({
password,
abortSignal,
})
await BiometricUnlockStorage.set(biometricStorageData)
},
retry: false,
onSettled: () => {
queryClient.invalidateQueries(biometricUnlockCredentialQuery())
},
onSuccess: options?.onSuccess,
onError: (error) => {
logger.error(error, {
tags: {
file: 'useBiometricUnlockSetupMutation.ts',
function: 'useBiometricUnlockSetupMutation',
},
})
options?.onError?.(error)
},
})
}
async function createCredentialAndEncryptPassword({
password,
abortSignal,
}: {
password: string
abortSignal: AbortSignal
}): Promise<BiometricUnlockStorageData> {
// Encrypt the password using the same approach we use in `Keyring`
const secretPayload = await createEmptySecretPayload()
const randomBuffer = generateNew256BitRandomBuffer()
const encryptionKey = await getEncryptionKeyFromBuffer({
buffer: randomBuffer,
secretPayload,
})
const rawKey = await window.crypto.subtle.exportKey('raw', encryptionKey)
const { credentialId, transports } = await createCredential({
encryptionKey: rawKey,
abortSignal,
})
return await encryptPasswordWithBiometricData({
password,
encryptionKey,
credentialId,
transports,
})
}
async function assertIsUserVerifyingPlatformAuthenticatorAvailable(): Promise<void> {
if (!(await isUserVerifyingPlatformAuthenticatorAvailable())) {
// This should never happen, as we should check for this before asking the user to create a biometric unlock credential.
throw new Error('Invalid call to setup biometric unlock when platform authenticator is not available')
}
}
const CREDENTIAL_NAME = 'Uniswap Extension'
// These algorithms provide a good balance of security, performance, and compatibility across different platforms.
// The order matters - the authenticator will typically choose the first algorithm it supports from this list.
const CREDENTIAL_ALGORITHMS: PublicKeyCredentialParameters[] = [
// ES256 (ECDSA with SHA-256) - Most widely supported algorithm, good balance of security and performance
{
type: 'public-key',
alg: -7,
},
// RS256 (RSA with SHA-256) - Strong security but slower than ES256
{
type: 'public-key',
alg: -257,
},
// EdDSA (Edwards-curve Digital Signature Algorithm) - Modern algorithm with good performance
{
type: 'public-key',
alg: -8,
},
]
async function createCredential({
encryptionKey,
abortSignal,
}: {
encryptionKey: ArrayBuffer
abortSignal: AbortSignal
}): Promise<{ credentialId: string; transports: AuthenticatorTransport[] }> {
// Create WebAuthn credential with platform authenticator (Touch ID, Windows Hello, etc.) forced
const credential = await navigator.credentials.create({
publicKey: {
challenge: generateNew256BitRandomBuffer(),
rp: {
name: CREDENTIAL_NAME,
id: window.location.hostname,
},
user: {
id: encryptionKey,
name: CREDENTIAL_NAME,
displayName: CREDENTIAL_NAME,
},
authenticatorSelection: {
authenticatorAttachment: 'platform',
residentKey: 'required',
userVerification: 'required',
},
pubKeyCredParams: CREDENTIAL_ALGORITHMS,
timeout: 15 * ONE_SECOND_MS,
// `hints` is a new property, only available in Chrome 128+.
// This forces the credential to use the built-in passkey instead of prompting the user where to save it.
hints: ['client-device'],
} as PublicKeyCredentialCreationOptionsWithHints,
signal: abortSignal,
})
const publicKeyCredential = assertPublicKeyCredential(credential)
// Convert raw ID to a storable string format
const credentialId = btoa(String.fromCharCode(...new Uint8Array(publicKeyCredential.rawId)))
const response = publicKeyCredential.response as AuthenticatorAttestationResponse
const transports = response.getTransports() as AuthenticatorTransport[]
return { credentialId, transports }
}
@@ -0,0 +1,532 @@
import { webcrypto } from 'node:crypto'
import { waitFor } from '@testing-library/react'
import { BiometricUnlockStorage } from 'src/app/features/biometricUnlock/BiometricUnlockStorage'
import { useChangePasswordWithBiometricMutation } from 'src/app/features/biometricUnlock/useChangePasswordWithBiometricMutation'
import { renderHookWithProviders } from 'src/test/render'
import { logger } from 'utilities/src/logger/logger'
import { encodeForStorage, encrypt, generateNew256BitRandomBuffer } from 'wallet/src/features/wallet/Keyring/crypto'
import { Keyring } from 'wallet/src/features/wallet/Keyring/Keyring'
// Mock dependencies
jest.mock('src/app/features/biometricUnlock/BiometricUnlockStorage')
jest.mock('wallet/src/features/wallet/Keyring/Keyring', () => ({
Keyring: {
changePassword: jest.fn(),
},
}))
jest.mock('utilities/src/logger/logger', () => ({
logger: {
error: jest.fn(),
},
}))
// Mock the Web Crypto API with Node.js built-in
Object.defineProperty(globalThis, 'crypto', {
value: webcrypto,
})
// Mock the WebAuthn API
const mockCredentialsGet = jest.fn()
Object.defineProperty(navigator, 'credentials', {
writable: true,
value: { get: mockCredentialsGet },
})
const mockBiometricUnlockStorage = BiometricUnlockStorage as jest.Mocked<typeof BiometricUnlockStorage>
const mockKeyring = Keyring as jest.Mocked<typeof Keyring>
const mockLogger = logger as jest.Mocked<typeof logger>
// Mock AuthenticatorAssertionResponse
class MockAuthenticatorAssertionResponse {
// oxlint-disable-next-line max-params
constructor(
public userHandle: ArrayBuffer | null,
public authenticatorData: ArrayBuffer = new ArrayBuffer(0),
public signature: ArrayBuffer = new ArrayBuffer(0),
public clientDataJSON: ArrayBuffer = new ArrayBuffer(0),
) {}
}
// Mock PublicKeyCredential
class MockPublicKeyCredential {
constructor(public response: AuthenticatorAssertionResponse) {}
}
Object.defineProperty(global, 'AuthenticatorAssertionResponse', {
writable: true,
value: MockAuthenticatorAssertionResponse,
})
Object.defineProperty(global, 'PublicKeyCredential', {
writable: true,
value: MockPublicKeyCredential,
})
describe('useChangePasswordWithBiometricMutation', () => {
const mockOldPassword = 'old-password-123'
const mockNewPassword = 'new-password-456'
const mockCredentialId = btoa('test-credential-id') // Use valid base64 encoded string
let mockEncryptionKey: CryptoKey
let mockEncryptionKeyBuffer: ArrayBuffer
let mockOldEncryptedPayload: {
ciphertext: string
iv: string
salt: string
name: string
iterations: number
hash: string
}
beforeEach(async () => {
// Create a real AES key for encryption/decryption
mockEncryptionKey = await globalThis.crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, [
'encrypt',
'decrypt',
])
// Export the key to use as userHandle
mockEncryptionKeyBuffer = await globalThis.crypto.subtle.exportKey('raw', mockEncryptionKey)
// Create real encrypted payload for the old password
const iv = generateNew256BitRandomBuffer()
const encryptedData = await encrypt({
encryptionKey: mockEncryptionKey,
plaintext: mockOldPassword,
additionalData: mockCredentialId,
iv,
})
mockOldEncryptedPayload = {
ciphertext: encryptedData,
iv: encodeForStorage(iv),
salt: '11,22,33,44,55,66,77,88,99,00,11,22,33,44,55,66', // Mock salt as comma-separated string
name: 'PBKDF2',
iterations: 100000,
hash: 'SHA-256',
}
// Setup default mocks
mockBiometricUnlockStorage.get.mockResolvedValue({
credentialId: mockCredentialId,
transports: ['internal'],
secretPayload: mockOldEncryptedPayload,
})
const mockAuthResponse = new MockAuthenticatorAssertionResponse(mockEncryptionKeyBuffer)
const mockPublicKeyCredential = new MockPublicKeyCredential(mockAuthResponse)
mockCredentialsGet.mockResolvedValue(mockPublicKeyCredential)
mockKeyring.changePassword.mockResolvedValue(true)
mockBiometricUnlockStorage.set.mockResolvedValue()
jest.clearAllMocks()
})
describe('successful password change', () => {
it('should successfully change password with biometric re-encryption', async () => {
const onSuccess = jest.fn()
const onError = jest.fn()
const { result } = renderHookWithProviders(() => useChangePasswordWithBiometricMutation({ onSuccess, onError }))
result.current.mutate(mockNewPassword)
await waitFor(() => {
expect(result.current.isSuccess).toBe(true)
})
// 1. Should retrieve biometric unlock credential from storage
expect(mockBiometricUnlockStorage.get).toHaveBeenCalledTimes(1)
// 2. Should authenticate with WebAuthn using the stored credential
const credentialIdBuffer = Uint8Array.from(atob(mockCredentialId), (c) => c.charCodeAt(0))
expect(mockCredentialsGet).toHaveBeenCalledWith({
publicKey: {
challenge: expect.any(Uint8Array),
allowCredentials: [
{
type: 'public-key',
id: credentialIdBuffer,
transports: ['internal'],
},
],
userVerification: 'required',
timeout: 15000, // 15 seconds
},
signal: expect.any(AbortSignal),
})
// 3. Should change the password in the keyring
expect(mockKeyring.changePassword).toHaveBeenCalledWith(mockNewPassword)
// 4. Should update the stored biometric data with re-encrypted password
expect(mockBiometricUnlockStorage.set).toHaveBeenCalledWith({
credentialId: mockCredentialId,
transports: ['internal'],
secretPayload: expect.objectContaining({
ciphertext: expect.any(String),
iv: expect.any(String),
salt: expect.any(String),
name: 'PBKDF2',
iterations: expect.any(Number),
hash: 'SHA-256',
}),
})
// 5. Should call onSuccess callback
expect(onSuccess).toHaveBeenCalledTimes(1)
expect(onError).not.toHaveBeenCalled()
})
it('should re-encrypt new password with the same encryption key', async () => {
const { result } = renderHookWithProviders(() => useChangePasswordWithBiometricMutation())
result.current.mutate(mockNewPassword)
await waitFor(() => {
expect(result.current.isSuccess).toBe(true)
})
// Verify that the new encrypted payload can be decrypted with the same key
const setCall = mockBiometricUnlockStorage.set.mock.calls[0]
const newBiometricData = setCall?.[0]
expect(newBiometricData?.credentialId).toBe(mockCredentialId)
expect(newBiometricData?.transports).toEqual(['internal'])
expect(newBiometricData?.secretPayload).toMatchObject({
ciphertext: expect.any(String),
iv: expect.any(String),
salt: expect.any(String),
name: 'PBKDF2',
iterations: expect.any(Number),
hash: 'SHA-256',
})
// The new payload should be different from the old one (different password encrypted)
expect(newBiometricData?.secretPayload.ciphertext).not.toBe(mockOldEncryptedPayload.ciphertext)
})
})
describe('error handling', () => {
it('should throw error when no biometric unlock credential found', async () => {
mockBiometricUnlockStorage.get.mockResolvedValue(null)
const onError = jest.fn()
const { result } = renderHookWithProviders(() => useChangePasswordWithBiometricMutation({ onError }))
result.current.mutate(mockNewPassword)
await waitFor(() => {
expect(result.current.isError).toBe(true)
})
expect(result.current.error?.message).toBe('No biometric unlock credential found')
expect(mockCredentialsGet).not.toHaveBeenCalled()
expect(mockKeyring.changePassword).not.toHaveBeenCalled()
expect(mockBiometricUnlockStorage.set).not.toHaveBeenCalled()
expect(onError).toHaveBeenCalledWith(expect.any(Error))
})
it('should throw error when biometric authentication fails', async () => {
mockCredentialsGet.mockResolvedValue(null)
const onError = jest.fn()
const { result } = renderHookWithProviders(() => useChangePasswordWithBiometricMutation({ onError }))
result.current.mutate(mockNewPassword)
await waitFor(() => {
expect(result.current.isError).toBe(true)
})
expect(result.current.error?.message).toBe('Failed to create credential')
expect(mockKeyring.changePassword).not.toHaveBeenCalled()
expect(mockBiometricUnlockStorage.set).not.toHaveBeenCalled()
expect(onError).toHaveBeenCalledWith(expect.any(Error))
})
it('should throw error when no user handle returned from authentication', async () => {
const mockAuthResponse = new MockAuthenticatorAssertionResponse(null) // No userHandle
const mockPublicKeyCredential = new MockPublicKeyCredential(mockAuthResponse)
mockCredentialsGet.mockResolvedValue(mockPublicKeyCredential)
const onError = jest.fn()
const { result } = renderHookWithProviders(() => useChangePasswordWithBiometricMutation({ onError }))
result.current.mutate(mockNewPassword)
await waitFor(() => {
expect(result.current.isError).toBe(true)
})
expect(result.current.error?.message).toBe('No user handle returned from biometric authentication')
expect(mockKeyring.changePassword).not.toHaveBeenCalled()
expect(mockBiometricUnlockStorage.set).not.toHaveBeenCalled()
expect(onError).toHaveBeenCalledWith(expect.any(Error))
})
it('should handle keyring password change failure', async () => {
const keyringError = new Error('Keyring password change failed')
mockKeyring.changePassword.mockRejectedValue(keyringError)
const onError = jest.fn()
const { result } = renderHookWithProviders(() => useChangePasswordWithBiometricMutation({ onError }))
result.current.mutate(mockNewPassword)
await waitFor(() => {
expect(result.current.isError).toBe(true)
})
expect(result.current.error).toBe(keyringError)
expect(mockBiometricUnlockStorage.set).not.toHaveBeenCalled()
expect(onError).toHaveBeenCalledWith(keyringError)
})
it('should handle biometric storage update failure', async () => {
const storageError = new Error('Storage update failed')
mockBiometricUnlockStorage.set.mockRejectedValue(storageError)
const onError = jest.fn()
const { result } = renderHookWithProviders(() => useChangePasswordWithBiometricMutation({ onError }))
result.current.mutate(mockNewPassword)
await waitFor(() => {
expect(result.current.isError).toBe(true)
})
expect(result.current.error).toBe(storageError)
expect(mockKeyring.changePassword).toHaveBeenCalledWith(mockNewPassword)
expect(onError).toHaveBeenCalledWith(storageError)
})
it('should handle WebAuthn API errors', async () => {
const webAuthnError = new Error('WebAuthn API error')
mockCredentialsGet.mockRejectedValue(webAuthnError)
const onError = jest.fn()
const { result } = renderHookWithProviders(() => useChangePasswordWithBiometricMutation({ onError }))
result.current.mutate(mockNewPassword)
await waitFor(() => {
expect(result.current.isError).toBe(true)
})
expect(result.current.error).toBe(webAuthnError)
expect(mockKeyring.changePassword).not.toHaveBeenCalled()
expect(mockBiometricUnlockStorage.set).not.toHaveBeenCalled()
expect(onError).toHaveBeenCalledWith(webAuthnError)
})
it('should handle storage retrieval errors', async () => {
const storageError = new Error('Storage retrieval failed')
mockBiometricUnlockStorage.get.mockRejectedValue(storageError)
const onError = jest.fn()
const { result } = renderHookWithProviders(() => useChangePasswordWithBiometricMutation({ onError }))
result.current.mutate(mockNewPassword)
await waitFor(() => {
expect(result.current.isError).toBe(true)
})
expect(result.current.error).toBe(storageError)
expect(mockCredentialsGet).not.toHaveBeenCalled()
expect(mockKeyring.changePassword).not.toHaveBeenCalled()
expect(mockBiometricUnlockStorage.set).not.toHaveBeenCalled()
expect(onError).toHaveBeenCalledWith(storageError)
})
it('should log errors when they occur', async () => {
const testError = new Error('Test error')
mockBiometricUnlockStorage.get.mockRejectedValue(testError)
const { result } = renderHookWithProviders(() => useChangePasswordWithBiometricMutation())
result.current.mutate(mockNewPassword)
await waitFor(() => {
expect(result.current.isError).toBe(true)
})
expect(mockLogger.error).toHaveBeenCalledWith(testError, {
tags: {
file: 'useChangePasswordWithBiometricMutation.ts',
function: 'changePasswordWithBiometric',
},
})
})
})
describe('callback handling', () => {
it('should work without callbacks provided', async () => {
const { result } = renderHookWithProviders(() => useChangePasswordWithBiometricMutation())
result.current.mutate(mockNewPassword)
await waitFor(() => {
expect(result.current.isSuccess).toBe(true)
})
// Should not throw any errors when no callbacks are provided
expect(mockKeyring.changePassword).toHaveBeenCalledWith(mockNewPassword)
expect(mockBiometricUnlockStorage.set).toHaveBeenCalled()
})
it('should call onSuccess callback when mutation succeeds', async () => {
const onSuccess = jest.fn()
const { result } = renderHookWithProviders(() => useChangePasswordWithBiometricMutation({ onSuccess }))
result.current.mutate(mockNewPassword)
await waitFor(() => {
expect(result.current.isSuccess).toBe(true)
})
expect(onSuccess).toHaveBeenCalledTimes(1)
})
it('should call onError callback when mutation fails', async () => {
const testError = new Error('Test error')
const onError = jest.fn()
mockBiometricUnlockStorage.get.mockRejectedValue(testError)
const { result } = renderHookWithProviders(() => useChangePasswordWithBiometricMutation({ onError }))
result.current.mutate(mockNewPassword)
await waitFor(() => {
expect(result.current.isError).toBe(true)
})
expect(onError).toHaveBeenCalledWith(testError)
})
it('should call both onSuccess and onError callbacks appropriately', async () => {
const onSuccess = jest.fn()
const onError = jest.fn()
const { result } = renderHookWithProviders(() => useChangePasswordWithBiometricMutation({ onSuccess, onError }))
// First test success case
result.current.mutate(mockNewPassword)
await waitFor(() => {
expect(result.current.isSuccess).toBe(true)
})
expect(onSuccess).toHaveBeenCalledTimes(1)
expect(onError).not.toHaveBeenCalled()
// Reset and test error case
jest.clearAllMocks()
onSuccess.mockClear()
onError.mockClear()
const testError = new Error('Test error')
mockBiometricUnlockStorage.get.mockRejectedValue(testError)
result.current.mutate(mockNewPassword)
await waitFor(() => {
expect(result.current.isError).toBe(true)
})
expect(onSuccess).not.toHaveBeenCalled()
expect(onError).toHaveBeenCalledWith(testError)
})
})
describe('mutation configuration', () => {
it('should not retry on failure', async () => {
mockBiometricUnlockStorage.get.mockResolvedValue(null)
const { result } = renderHookWithProviders(() => useChangePasswordWithBiometricMutation())
result.current.mutate(mockNewPassword)
await waitFor(() => {
expect(result.current.isError).toBe(true)
})
// Should only be called once (no retries)
expect(mockBiometricUnlockStorage.get).toHaveBeenCalledTimes(1)
})
it('should have correct mutation function signature', () => {
const { result } = renderHookWithProviders(() => useChangePasswordWithBiometricMutation())
expect(typeof result.current.mutate).toBe('function')
expect(typeof result.current.mutateAsync).toBe('function')
expect(typeof result.current.reset).toBe('function')
expect(result.current.isPending).toBe(false)
expect(result.current.isError).toBe(false)
expect(result.current.isSuccess).toBe(false)
expect(result.current.data).toBeUndefined()
expect(result.current.error).toBeNull()
})
it('should handle abort signal properly', async () => {
// Mock a scenario where the WebAuthn request is aborted
const abortError = new DOMException('The operation was aborted.', 'AbortError')
mockCredentialsGet.mockRejectedValue(abortError)
const { result } = renderHookWithProviders(() => useChangePasswordWithBiometricMutation())
result.current.mutate(mockNewPassword)
await waitFor(() => {
expect(result.current.isError).toBe(true)
})
expect(result.current.error).toBe(abortError)
expect(mockKeyring.changePassword).not.toHaveBeenCalled()
})
})
describe('WebAuthn integration', () => {
it('should use correct WebAuthn parameters', async () => {
const { result } = renderHookWithProviders(() => useChangePasswordWithBiometricMutation())
result.current.mutate(mockNewPassword)
await waitFor(() => {
expect(mockCredentialsGet).toHaveBeenCalled()
})
const webAuthnCall = mockCredentialsGet.mock.calls[0][0]
expect(webAuthnCall.publicKey).toMatchObject({
challenge: expect.any(Uint8Array),
allowCredentials: [
{
type: 'public-key',
id: expect.any(Uint8Array),
},
],
userVerification: 'required',
timeout: 15000,
})
expect(webAuthnCall.signal).toBeInstanceOf(AbortSignal)
})
it('should convert credential ID properly for WebAuthn', async () => {
const { result } = renderHookWithProviders(() => useChangePasswordWithBiometricMutation())
result.current.mutate(mockNewPassword)
await waitFor(() => {
expect(mockCredentialsGet).toHaveBeenCalled()
})
const webAuthnCall = mockCredentialsGet.mock.calls[0][0]
const allowedCredential = webAuthnCall.publicKey.allowCredentials[0]
const expectedCredentialId = Uint8Array.from(atob(mockCredentialId), (c) => c.charCodeAt(0))
expect(allowedCredential.id).toEqual(expectedCredentialId)
})
})
})
@@ -0,0 +1,68 @@
import { UseMutationResult, useMutation } from '@tanstack/react-query'
>>>>>>> upstream/main
import {
authenticateWithBiometricCredential,
encryptPasswordWithBiometricData,
} from 'src/app/features/biometricUnlock/biometricAuthUtils'
<<<<<<< HEAD
import { startNavigatorCredentialRequest } from 'src/app/features/biometricUnlock/useNavigatorCredentialAbortSignal'
import { logger } from '@l.x/utils/src/logger/logger'
import { useEvent } from '@l.x/utils/src/react/hooks'
import { Keyring } from '@luxfi/wallet/src/features/wallet/Keyring/Keyring'
=======
import { BiometricUnlockStorage } from 'src/app/features/biometricUnlock/BiometricUnlockStorage'
import { startNavigatorCredentialRequest } from 'src/app/features/biometricUnlock/useNavigatorCredentialAbortSignal'
import { logger } from 'utilities/src/logger/logger'
import { useEvent } from 'utilities/src/react/hooks'
import { Keyring } from 'wallet/src/features/wallet/Keyring/Keyring'
export function useChangePasswordWithBiometricMutation(options?: {
onSuccess?: () => void
onError?: (error: Error) => void
}): UseMutationResult<void, Error, string> {
const changePasswordWithBiometric = useEvent(async (newPassword: string): Promise<void> => {
const { abortSignal } = startNavigatorCredentialRequest('Password change with biometric re-encryption initiated')
// Get the current biometric credential data
const biometricUnlockCredential = await BiometricUnlockStorage.get()
if (!biometricUnlockCredential) {
throw new Error('No biometric unlock credential found')
}
// Authenticate with WebAuthn to get the encryption key
const { encryptionKey } = await authenticateWithBiometricCredential({
credentialId: biometricUnlockCredential.credentialId,
transports: biometricUnlockCredential.transports,
abortSignal,
})
// Change the password in the keyring
await Keyring.changePassword(newPassword)
// Re-encrypt the new password with the same encryption key
const newBiometricData = await encryptPasswordWithBiometricData({
password: newPassword,
encryptionKey,
credentialId: biometricUnlockCredential.credentialId,
transports: biometricUnlockCredential.transports,
})
// Update the stored biometric data
await BiometricUnlockStorage.set(newBiometricData)
})
return useMutation({
mutationFn: changePasswordWithBiometric,
retry: false,
onSuccess: options?.onSuccess,
onError: (error) => {
logger.error(error, {
tags: {
file: 'useChangePasswordWithBiometricMutation.ts',
function: 'changePasswordWithBiometric',
},
})
options?.onError?.(error)
},
})
}
@@ -0,0 +1,11 @@
const abortControllerRef: { current: AbortController | null } = { current: null }
export function startNavigatorCredentialRequest(reason: string): { abortSignal: AbortSignal } {
// Cancel any pending requests.
// This is needed because of a bug in how Chrome handles passkey requests inside the side panel.
// Chrome bug: https://issues.chromium.org/issues/381056235
// Video: https://github.com/Uniswap/universe/pull/21241
abortControllerRef.current?.abort(reason)
abortControllerRef.current = new AbortController()
return { abortSignal: abortControllerRef.current.signal }
}
@@ -0,0 +1,20 @@
import { useQuery } from '@tanstack/react-query'
import { DynamicConfigs, ExtensionBiometricUnlockConfigKey, useDynamicConfigValue } from '@universe/gating'
import { biometricUnlockCredentialQuery } from 'src/app/features/biometricUnlock/biometricUnlockCredentialQuery'
export function useShouldShowBiometricUnlock(): boolean {
const isEnabled = useDynamicConfigValue({
config: DynamicConfigs.ExtensionBiometricUnlock,
key: ExtensionBiometricUnlockConfigKey.EnableUnlocking,
defaultValue: true,
})
const hasBiometricUnlockCredential = useHasBiometricUnlockCredential()
return isEnabled && hasBiometricUnlockCredential
}
export function useHasBiometricUnlockCredential(): boolean {
const { data: biometricUnlockCredential } = useQuery(biometricUnlockCredentialQuery())
return !!biometricUnlockCredential
}
@@ -0,0 +1,22 @@
import { useQuery } from '@tanstack/react-query'
import { DynamicConfigs, ExtensionBiometricUnlockConfigKey, useDynamicConfigValue } from '@universe/gating'
import { useTranslation } from 'react-i18next'
import { builtInBiometricCapabilitiesQuery } from 'src/app/utils/device/builtInBiometricCapabilitiesQuery'
export function useShouldShowBiometricUnlockEnrollment({ flow }: { flow: 'onboarding' | 'settings' }): boolean {
const { t } = useTranslation()
const isEnabled = useDynamicConfigValue({
config: DynamicConfigs.ExtensionBiometricUnlock,
key:
flow === 'onboarding'
? ExtensionBiometricUnlockConfigKey.EnableOnboardingEnrollment
: ExtensionBiometricUnlockConfigKey.EnableSettingsEnrollment,
defaultValue: false,
})
const { data: biometricCapabilities } = useQuery(builtInBiometricCapabilitiesQuery({ t }))
const shouldShowBiometricUnlockEnrollment = isEnabled && Boolean(biometricCapabilities?.hasBuiltInBiometricSensor)
return shouldShowBiometricUnlockEnrollment
}
@@ -0,0 +1,272 @@
import { webcrypto } from 'node:crypto'
import { waitFor } from '@testing-library/react'
import { BiometricUnlockStorage } from 'src/app/features/biometricUnlock/BiometricUnlockStorage'
import { useUnlockWithBiometricCredentialMutation } from 'src/app/features/biometricUnlock/useUnlockWithBiometricCredentialMutation'
import { renderHookWithProviders } from 'src/test/render'
import { encodeForStorage, encrypt, generateNew256BitRandomBuffer } from 'wallet/src/features/wallet/Keyring/crypto'
jest.mock('src/app/features/biometricUnlock/BiometricUnlockStorage')
const mockUnlockWithPassword = jest.fn()
jest.mock('src/app/features/lockScreen/useUnlockWithPassword', () => ({
useUnlockWithPassword: jest.fn(() => mockUnlockWithPassword),
}))
// Mock the Web Crypto API with Node.js built-in
Object.defineProperty(globalThis, 'crypto', {
value: webcrypto,
})
// Mock the WebAuthn API
const mockCredentialsGet = jest.fn()
Object.defineProperty(navigator, 'credentials', {
writable: true,
value: { get: mockCredentialsGet },
})
const mockBiometricUnlockStorage = BiometricUnlockStorage as jest.Mocked<typeof BiometricUnlockStorage>
// Mock AuthenticatorAssertionResponse
class MockAuthenticatorAssertionResponse {
// oxlint-disable-next-line max-params
constructor(
public userHandle: ArrayBuffer | null,
public authenticatorData: ArrayBuffer = new ArrayBuffer(0),
public signature: ArrayBuffer = new ArrayBuffer(0),
public clientDataJSON: ArrayBuffer = new ArrayBuffer(0),
) {}
}
// Mock PublicKeyCredential
class MockPublicKeyCredential {
constructor(public response: AuthenticatorAssertionResponse) {}
}
Object.defineProperty(global, 'AuthenticatorAssertionResponse', {
writable: true,
value: MockAuthenticatorAssertionResponse,
})
Object.defineProperty(global, 'PublicKeyCredential', {
writable: true,
value: MockPublicKeyCredential,
})
describe('useUnlockWithBiometricCredentialMutation', () => {
const mockPassword = 'test-password-123'
const mockCredentialId = btoa('test-credential-id') // Use valid base64 encoded string
let mockEncryptionKey: CryptoKey
let mockEncryptionKeyBuffer: ArrayBuffer
let mockEncryptedPayload: {
ciphertext: string
iv: string
salt: string
name: string
iterations: number
hash: string
}
beforeEach(async () => {
// Create a real AES key for encryption/decryption
mockEncryptionKey = await globalThis.crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, [
'encrypt',
'decrypt',
])
// Export the key to use as userHandle
mockEncryptionKeyBuffer = await globalThis.crypto.subtle.exportKey('raw', mockEncryptionKey)
// Create real encrypted payload
const iv = generateNew256BitRandomBuffer()
const encryptedData = await encrypt({
encryptionKey: mockEncryptionKey,
plaintext: mockPassword,
additionalData: mockCredentialId,
iv,
})
mockEncryptedPayload = {
ciphertext: encryptedData,
iv: encodeForStorage(iv),
salt: '11,22,33,44,55,66,77,88,99,00,11,22,33,44,55,66', // Mock salt as comma-separated string
name: 'PBKDF2',
iterations: 100000,
hash: 'SHA-256',
}
// Setup default mocks
mockBiometricUnlockStorage.get.mockResolvedValue({
credentialId: mockCredentialId,
transports: ['internal'],
secretPayload: mockEncryptedPayload,
})
const mockAuthResponse = new MockAuthenticatorAssertionResponse(mockEncryptionKeyBuffer)
const mockPublicKeyCredential = new MockPublicKeyCredential(mockAuthResponse)
mockCredentialsGet.mockResolvedValue(mockPublicKeyCredential)
// Reset and configure mockUnlockWithPassword
mockUnlockWithPassword.mockReset()
mockUnlockWithPassword.mockResolvedValue(undefined)
})
describe('successful unlock', () => {
it('should successfully unlock with biometric credential', async () => {
const { result } = renderHookWithProviders(() => useUnlockWithBiometricCredentialMutation())
result.current.mutate()
await waitFor(() => {
expect(result.current.isSuccess).toBe(true)
})
// 1. Should retrieve biometric unlock credential from storage
expect(mockBiometricUnlockStorage.get).toHaveBeenCalledTimes(1)
// 2. Should authenticate with WebAuthn using the stored credential
const credentialIdBuffer = Uint8Array.from(atob(mockCredentialId), (c) => c.charCodeAt(0))
expect(mockCredentialsGet).toHaveBeenCalledWith({
publicKey: {
challenge: expect.any(Uint8Array),
allowCredentials: [
{
type: 'public-key',
id: credentialIdBuffer,
transports: ['internal'],
},
],
userVerification: 'required',
timeout: 15000, // 15 seconds
},
signal: expect.any(AbortSignal),
})
// 3. Should call unlockWithPassword with the decrypted password
expect(mockUnlockWithPassword).toHaveBeenCalledWith({ password: mockPassword })
})
})
describe('error handling', () => {
it('should throw error when no biometric unlock credential found', async () => {
mockBiometricUnlockStorage.get.mockResolvedValue(null)
const { result } = renderHookWithProviders(() => useUnlockWithBiometricCredentialMutation())
result.current.mutate()
await waitFor(() => {
expect(result.current.isError).toBe(true)
})
expect(result.current.error?.message).toBe('No biometric unlock credential found')
expect(mockCredentialsGet).not.toHaveBeenCalled()
expect(mockUnlockWithPassword).not.toHaveBeenCalled()
})
it('should throw error when biometric authentication fails', async () => {
mockCredentialsGet.mockResolvedValue(null)
const { result } = renderHookWithProviders(() => useUnlockWithBiometricCredentialMutation())
result.current.mutate()
await waitFor(() => {
expect(result.current.isError).toBe(true)
})
expect(result.current.error?.message).toBe('Failed to create credential')
expect(mockUnlockWithPassword).not.toHaveBeenCalled()
})
it('should throw error when no user handle returned from authentication', async () => {
const mockAuthResponse = new MockAuthenticatorAssertionResponse(null) // No userHandle
const mockPublicKeyCredential = new MockPublicKeyCredential(mockAuthResponse)
mockCredentialsGet.mockResolvedValue(mockPublicKeyCredential)
const { result } = renderHookWithProviders(() => useUnlockWithBiometricCredentialMutation())
result.current.mutate()
await waitFor(() => {
expect(result.current.isError).toBe(true)
})
expect(result.current.error?.message).toBe('No user handle returned from biometric authentication')
expect(mockUnlockWithPassword).not.toHaveBeenCalled()
})
it('should throw error when password decryption fails', async () => {
// Use a different encryption key for decryption (will cause decryption to fail)
const differentKey = await globalThis.crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, [
'encrypt',
'decrypt',
])
const differentKeyBuffer = await globalThis.crypto.subtle.exportKey('raw', differentKey)
const mockAuthResponse = new MockAuthenticatorAssertionResponse(differentKeyBuffer)
const mockPublicKeyCredential = new MockPublicKeyCredential(mockAuthResponse)
mockCredentialsGet.mockResolvedValue(mockPublicKeyCredential)
const { result } = renderHookWithProviders(() => useUnlockWithBiometricCredentialMutation())
result.current.mutate()
await waitFor(() => {
expect(result.current.isError).toBe(true)
})
expect(result.current.error?.message).toBe('Failed to decrypt password')
expect(mockUnlockWithPassword).not.toHaveBeenCalled()
})
it('should handle WebAuthn API errors', async () => {
const webAuthnError = new Error('WebAuthn API error')
mockCredentialsGet.mockRejectedValue(webAuthnError)
const { result } = renderHookWithProviders(() => useUnlockWithBiometricCredentialMutation())
result.current.mutate()
await waitFor(() => {
expect(result.current.isError).toBe(true)
})
expect(result.current.error).toBe(webAuthnError)
expect(mockUnlockWithPassword).not.toHaveBeenCalled()
})
it('should handle storage retrieval errors', async () => {
const storageError = new Error('Storage retrieval failed')
mockBiometricUnlockStorage.get.mockRejectedValue(storageError)
const { result } = renderHookWithProviders(() => useUnlockWithBiometricCredentialMutation())
result.current.mutate()
await waitFor(() => {
expect(result.current.isError).toBe(true)
})
expect(result.current.error).toBe(storageError)
expect(mockCredentialsGet).not.toHaveBeenCalled()
expect(mockUnlockWithPassword).not.toHaveBeenCalled()
})
})
describe('mutation configuration', () => {
it('should not retry on failure', async () => {
mockBiometricUnlockStorage.get.mockResolvedValue(null)
const { result } = renderHookWithProviders(() => useUnlockWithBiometricCredentialMutation())
result.current.mutate()
await waitFor(() => {
expect(result.current.isError).toBe(true)
})
// Should only be called once (no retries)
expect(mockBiometricUnlockStorage.get).toHaveBeenCalledTimes(1)
})
})
})
@@ -0,0 +1,75 @@
import { UseMutationResult, useMutation } from '@tanstack/react-query'
>>>>>>> upstream/main
import {
authenticateWithBiometricCredential,
decryptPasswordFromBiometricData,
} from 'src/app/features/biometricUnlock/biometricAuthUtils'
<<<<<<< HEAD
import { startNavigatorCredentialRequest } from 'src/app/features/biometricUnlock/useNavigatorCredentialAbortSignal'
import { useUnlockWithPassword } from 'src/app/features/lockScreen/useUnlockWithPassword'
import { logger } from '@l.x/utils/src/logger/logger'
import { useEvent } from '@l.x/utils/src/react/hooks'
import { Keyring } from '@luxfi/wallet/src/features/wallet/Keyring/Keyring'
=======
import { BiometricUnlockStorage } from 'src/app/features/biometricUnlock/BiometricUnlockStorage'
import { startNavigatorCredentialRequest } from 'src/app/features/biometricUnlock/useNavigatorCredentialAbortSignal'
import { useUnlockWithPassword } from 'src/app/features/lockScreen/useUnlockWithPassword'
import { logger } from 'utilities/src/logger/logger'
import { useEvent } from 'utilities/src/react/hooks'
import { Keyring } from 'wallet/src/features/wallet/Keyring/Keyring'
export function useUnlockWithBiometricCredentialMutation(): UseMutationResult<void, Error, void> {
const unlockWithPassword = useUnlockWithPassword()
const unlockWithBiometric = useEvent(async (): Promise<void> => {
const { abortSignal } = startNavigatorCredentialRequest('New biometric unlock request initiated')
const password = await getPasswordFromBiometricCredential(abortSignal)
unlockWithPassword({ password })
})
return useMutation({
mutationFn: unlockWithBiometric,
retry: false,
onError: (error) => {
logger.error(error, {
tags: {
file: 'useUnlockWithBiometricCredentialMutation.ts',
function: 'unlockWithBiometric',
},
})
},
})
}
/**
* Reauthenticates a user with their biometric credential.
* Meant to be used when the Extension is already unlocked but we want to prompt the user to re-authenticate.
* For example, when viewing the seed phrase or changing their password.
*
* @returns the user's password if authentication is successful, null otherwise.
*/
export async function reauthenticateWithBiometricCredential(): Promise<{ password: string | null }> {
try {
const { abortSignal } = startNavigatorCredentialRequest('New biometric reauthentication request initiated')
const password = await getPasswordFromBiometricCredential(abortSignal)
const success = await Keyring.checkPassword(password)
return { password: success ? password : null }
} catch {
return { password: null }
}
}
async function getPasswordFromBiometricCredential(abortSignal: AbortSignal): Promise<string> {
const biometricUnlockCredential = await BiometricUnlockStorage.get()
if (!biometricUnlockCredential) {
throw new Error('No biometric unlock credential found')
}
const { credentialId, transports } = biometricUnlockCredential
// Authenticate with WebAuthn using the stored credential and decrypt password
const { encryptionKey } = await authenticateWithBiometricCredential({ credentialId, transports, abortSignal })
const password = await decryptPasswordFromBiometricData({ encryptionKey, biometricUnlockCredential })
return password
}
@@ -0,0 +1,7 @@
export function assertAuthenticatorAssertionResponse(
response: AuthenticatorResponse,
): asserts response is AuthenticatorAssertionResponse {
if (!('authenticatorData' in response) || !('signature' in response) || !('userHandle' in response)) {
throw new Error('Expected `AuthenticatorAssertionResponse` but received different response type')
}
}
@@ -0,0 +1,11 @@
export function assertPublicKeyCredential(credential: Credential | null): PublicKeyCredential {
if (!credential) {
throw new Error('Failed to create credential')
}
if (!(credential instanceof PublicKeyCredential)) {
throw new Error('Created credential is not a `PublicKeyCredential`')
}
return credential
}
@@ -0,0 +1,78 @@
import { createContext, ReactNode, useContext, useEffect, useState } from 'react'
import { useDispatch } from 'react-redux'
import { updateDisplayNameFromTab } from 'src/app/features/dapp/actions'
import { useDappConnectedAccounts, useDappLastChainId } from 'src/app/features/dapp/hooks'
import { dappStore } from 'src/app/features/dapp/store'
import { isConnectedAccount } from 'src/app/features/dapp/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 { UniverseChainId } from 'uniswap/src/features/chains/types'
import { extractBaseUrl } from 'utilities/src/format/urls'
import { useActiveAccountAddress } from 'wallet/src/features/wallet/hooks'
type DappContextState = {
dappUrl: string
dappIconUrl?: string
isConnected: boolean
lastChainId?: UniverseChainId
}
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 = async (): Promise<void> => {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true })
if (tab) {
const newDappUrl = extractBaseUrl(tab.url)
setDappUrl(newDappUrl || '')
setDappIconUrl(tab.favIconUrl)
if (!newDappUrl) {
return
}
dappStore.updateDappIconUrl(newDappUrl, tab.favIconUrl)
await updateDisplayNameFromTab(newDappUrl)
}
}
// need to update dapp info on mount
// oxlint-disable-next-line typescript/no-floating-promises
updateDappInfo()
return backgroundToSidePanelMessageChannel.addMessageListener(
BackgroundToSidePanelRequestType.TabActivated,
async (_message) => {
await updateDappInfo()
dispatch(closePopup(PopupName.Connect))
},
)
}, [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
}
@@ -0,0 +1,113 @@
import { DappInfo, dappStore } from 'src/app/features/dapp/store'
import { getCapitalizedDisplayNameFromTab } from 'src/app/features/dapp/utils'
import { externalDappMessageChannel } from 'src/background/messagePassing/messageChannels'
import {
ExtensionChainChange,
ExtensionToDappRequestType,
UpdateConnectionRequest,
} from 'src/background/messagePassing/types/requests'
import { UniverseChainId } from 'uniswap/src/features/chains/types'
import { chainIdToHexadecimalString } from 'uniswap/src/features/chains/utils'
import { Account } from 'wallet/src/features/wallet/accounts/types'
import { getProviderSync } from 'wallet/src/features/wallet/context'
export async function saveDappChain(dappUrl: string, chainId: UniverseChainId): 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,
account,
iconUrl,
}: {
dappUrl: string
account: Account
iconUrl?: string
}): Promise<void> {
const displayName = await getCapitalizedDisplayNameFromTab(dappUrl)
const initialProperties: Partial<DappInfo> = {}
if (displayName) {
initialProperties.displayName = displayName
}
if (iconUrl) {
initialProperties.iconUrl = iconUrl
}
dappStore.saveDappActiveAccount({ dappUrl, account, initialProperties })
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)
}
/**
* Set the display name of a dapp from the tab title
* @param dappUrl - extracted url for dapp
*/
export async function updateDisplayNameFromTab(dappUrl: string): Promise<void> {
// do not update if dapp is not in state
if (!dappStore.getDappInfo(dappUrl)) {
return
}
const displayName = await getCapitalizedDisplayNameFromTab(dappUrl)
// no-op if display name isn't found (prevents overwriting existing display name)
if (!displayName) {
return
}
dappStore.updateDappDisplayName(dappUrl, displayName)
}
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)
dappStore.removeAccountDappConnections(account)
for (const dappUrl of connectedDapps) {
await updateConnectionFromExtension(dappUrl)
}
}
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()
}
@@ -0,0 +1,115 @@
import { JsonRpcProvider } from '@ethersproject/providers'
import { providerErrors, serializeError } from '@metamask/rpc-errors'
import { changeChain } from 'src/app/features/dapp/changeChain'
import { dappStore } from 'src/app/features/dapp/store'
import { UniverseChainId } from 'uniswap/src/features/chains/types'
import { chainIdToHexadecimalString } from 'uniswap/src/features/chains/utils'
import { DappResponseType } from 'uniswap/src/features/dappRequests/types'
import { ExtensionEventName } from 'uniswap/src/features/telemetry/constants'
import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send'
// 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 UniverseChainId
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,
})
})
})
@@ -0,0 +1,66 @@
import { JsonRpcProvider } from '@ethersproject/providers'
import { providerErrors, serializeError } from '@metamask/rpc-errors'
import { dappStore } from 'src/app/features/dapp/store'
import { ChangeChainResponse, ErrorResponse } from 'src/app/features/dappRequests/types/DappRequestTypes'
import { UniverseChainId } from 'uniswap/src/features/chains/types'
import { chainIdToHexadecimalString } from 'uniswap/src/features/chains/utils'
import { DappResponseType } from 'uniswap/src/features/dappRequests/types'
import { ExtensionEventName } from 'uniswap/src/features/telemetry/constants'
import { sendAnalyticsEvent } from 'uniswap/src/features/telemetry/send'
export function changeChain({
activeConnectedAddress,
dappUrl,
provider,
requestId,
updatedChainId,
}: {
activeConnectedAddress: Address | undefined
dappUrl: string | undefined
provider: JsonRpcProvider | undefined | null
requestId: string
updatedChainId: UniverseChainId | 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,
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,
}
}
@@ -0,0 +1,143 @@
import {
useAllDappConnectionsForAccount,
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/features/chains/types'
import { SAMPLE_SEED_ADDRESS_1, SAMPLE_SEED_ADDRESS_3 } from 'uniswap/src/test/fixtures'
import { useActiveAccountAddress } from 'wallet/src/features/wallet/hooks'
import { ACCOUNT, ACCOUNT2, ACCOUNT3 } from 'wallet/src/test/fixtures'
jest.mock('wallet/src/features/wallet/hooks', () => ({
...jest.requireActual('wallet/src/features/wallet/hooks'),
useActiveAccountAddress: jest.fn(),
}))
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]))
})
describe('useAllDappConnectionsForAccount', () => {
it('should return connections for a specific address when provided', async () => {
// ACCOUNT2 (SAMPLE_SEED_ADDRESS_2) is only connected to SAMPLE_DAPP
const { result } = renderHook(() => useAllDappConnectionsForAccount(ACCOUNT2.address))
await waitFor(() => expect(result.current).toEqual([SAMPLE_DAPP]))
})
it('should return connections for address connected to multiple dapps', async () => {
// ACCOUNT (SAMPLE_SEED_ADDRESS_1) is connected to both dapps
const { result } = renderHook(() => useAllDappConnectionsForAccount(ACCOUNT.address))
await waitFor(() => expect(result.current).toEqual(expect.arrayContaining([SAMPLE_DAPP, SAMPLE_DAPP_2])))
await waitFor(() => expect(result.current).toHaveLength(2))
})
it('should return empty array when address has no connections', async () => {
const unconnectedAddress = '0x0000000000000000000000000000000000000000'
const { result } = renderHook(() => useAllDappConnectionsForAccount(unconnectedAddress))
await waitFor(() => expect(result.current).toEqual([]))
})
it('should use active account when no address is provided', async () => {
// Mock useActiveAccountAddress to return ACCOUNT3's address
jest.mocked(useActiveAccountAddress).mockReturnValue(ACCOUNT3.address)
// ACCOUNT3 (SAMPLE_SEED_ADDRESS_3) is only connected to SAMPLE_DAPP_2
const { result } = renderHook(() => useAllDappConnectionsForAccount())
await waitFor(() => expect(result.current).toEqual([SAMPLE_DAPP_2]))
})
it('should return empty array when no address provided and no active account', async () => {
jest.mocked(useActiveAccountAddress).mockReturnValue(null)
const { result } = renderHook(() => useAllDappConnectionsForAccount())
await waitFor(() => expect(result.current).toEqual([]))
})
})
})

Some files were not shown because too many files have changed in this diff Show More