mirror of
https://github.com/luxfi/wallet.git
synced 2026-07-26 22:49:14 +00:00
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:
+17
@@ -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
|
||||
@@ -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',
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
jest-setup.js
|
||||
@@ -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),
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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/
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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'} —
|
||||
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 }
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 />)
|
||||
Vendored
+16
@@ -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 {}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -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
|
||||
@@ -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'],
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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%',
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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' },
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+734
@@ -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',
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
+254
@@ -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 }
|
||||
}
|
||||
+532
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
+68
@@ -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
|
||||
}
|
||||
+22
@@ -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
|
||||
}
|
||||
+272
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
+75
@@ -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
|
||||
}
|
||||
+7
@@ -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
Reference in New Issue
Block a user